History

fix(orchestration): clarify agent delegation and scheduling

Julius Marminge committed 3 months ago

Commit 85f47fa1 parent f4f1e3f15 files · +326 −45Browse files

Unified · Split

15 files
  1. apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +21 −0
  2. apps/server/src/mcp/toolkits/orchestrator/tools.test.ts +28 −0
  3. apps/server/src/mcp/toolkits/orchestrator/tools.ts +4 −4
  4. apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +8 −2
  5. apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +9 −0
  6. apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +10 −0
  7. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +29 −0
  8. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +19 −7
  9. apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +8 −2
  10. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +10 −1
  11. apps/server/src/provider/CodexDeveloperInstructions.ts +5 −10
  12. apps/server/src/provider/T3OrchestrationInstructions.test.ts +47 −0
  13. apps/server/src/provider/T3OrchestrationInstructions.ts +35 −0
  14. packages/contracts/src/orchestratorMcp.ts +66 −13
  15. packages/contracts/src/scheduledTask.ts +27 −6
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+21−0
@@ -668,6 +668,27 @@ describe("orchestrator MCP toolkit", () => { · 667 unmodified lines
668668 });
669669 expect(yield* Ref.get(scheduledStore)).toHaveLength(0);
670670
671+ // OpenCode 1.15 has emitted this exact nested-object-as-JSON-string
672+ // shape. Decode it at the MCP boundary rather than failing a task
673+ // the model otherwise specified correctly.
674+ const serializedScheduleCall = yield* invoke("schedule_task", {
675+ prompt: "check for new pull requests",
676+ schedule: '{"type":"interval","everyMs":3600000}',
677+ clientRequestId: "schedule-opencode-compat-1",
678+ });
679+ expect(serializedScheduleCall.isError).toBe(false);
680+ expect(serializedScheduleCall.structuredContent).toMatchObject({
681+ schedule: { type: "interval", everyMs: 3_600_000 },
682+ boundThreadId: parentThreadId,
683+ });
684+ const serializedScheduledTaskId = (
685+ serializedScheduleCall.structuredContent as { scheduledTaskId: string }
686+ ).scheduledTaskId;
687+ yield* invoke("delete_scheduled_task", {
688+ scheduledTaskId: serializedScheduledTaskId,
689+ });
690+ expect(yield* Ref.get(scheduledStore)).toHaveLength(0);
691+
671692 const delegatedCall = yield* invoke("delegate_task", {
672693 task: delegatedPrompt,
673694 target: {
apps/server/src/mcp/toolkits/orchestrator/tools.test.ts+28−0
@@ -0,0 +1,28 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import { Tool } from "effect/unstable/ai";
3+
4+import { CreateThreadsTool, DelegateTaskTool, ScheduleTaskTool } from "./tools.ts";
5+
6+describe("orchestrator MCP tool guidance", () => {
7+ it("directs subagent requests to delegation instead of ordinary threads", () => {
8+ assert.include(DelegateTaskTool.description ?? "", "child agent/subagent");
9+ assert.include(DelegateTaskTool.description ?? "", "cross-provider");
10+ assert.include(CreateThreadsTool.description ?? "", "not delegation");
11+ assert.include(CreateThreadsTool.description ?? "", "call delegate_task");
12+ });
13+
14+ it("publishes an actionable schedule schema and compatibility string branch", () => {
15+ const schema = Tool.getJsonSchema(ScheduleTaskTool) as {
16+ readonly type?: unknown;
17+ readonly properties?: Readonly<
18+ Record<string, { readonly description?: unknown; readonly anyOf?: ReadonlyArray<unknown> }>
19+ >;
20+ };
21+
22+ assert.equal(schema.type, "object");
23+ assert.isString(schema.properties?.schedule?.description);
24+ assert.isAtLeast(schema.properties?.schedule?.anyOf?.length ?? 0, 2);
25+ assert.include(ScheduleTaskTool.description ?? "", "STRUCTURED OBJECT");
26+ assert.include(ScheduleTaskTool.description ?? "", "nextRunAt");
27+ });
28+});
apps/server/src/mcp/toolkits/orchestrator/tools.ts+4−4
@@ -49,7 +49,7 @@ export const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities · 48 unmodified lines
4949
5050 export const DelegateTaskTool = Tool.make("delegate_task", {
5151 description:
52− "Create a T3-owned child agent thread and run it with only the supplied task prompt, without copying parent conversation history. Provider, model, model options (e.g. reasoning effort — see orchestrator_capabilities for valid ids), runtime mode, and interaction mode inherit from the parent unless overridden via target. Prefer mode='async' and poll task_status for long work; mode='wait' blocks until completion or timeout.",
52+ "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Use this whenever the user asks for an agent, subagent, worker, delegated task, or parallel help—including cross-provider work. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' and poll task_status for long work; mode='wait' blocks until completion or timeout.",
5353 parameters: OrchestratorMcpDelegateTaskInput,
5454 success: OrchestratorMcpDelegateTaskResult,
5555 failure: OrchestratorMcpFailure,
@@ -88,7 +88,7 @@ export const TaskCancelTool = Tool.make("task_cancel", { · 32 unmodified lines
8888
8989 export const ScheduleTaskTool = Tool.make("schedule_task", {
9090 description:
91− "Create a recurring scheduled task that automatically runs a prompt on a schedule. Use this to set up autonomous recurring work — the app's scheduler fires it even when no turn is active. By default (bindToCurrentThread=true) each run posts into THIS thread, which is how you 'wake up' and continue here on an interval or at a fixed time; set bindToCurrentThread=false to launch a fresh thread each run instead. Provider, model, and runtime settings inherit from the calling thread. Schedule is either {type:'interval', everyMs} (e.g. everyMs=60000 for every minute) or {type:'fixed_time', timeOfDay:'HH:MM', weekdays?:[0-6] where 0=Sunday}.",
91+ "Create persistent recurring work in the app scheduler, which runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text: {type:'interval', everyMs:3600000} means hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} means weekday mornings. By default (bindToCurrentThread=true) each run posts into THIS thread; use false only when the user wants a fresh top-level thread per run. Provider, model, and runtime settings inherit from this thread. Report the returned schedule and nextRunAt after success.",
9292 parameters: OrchestratorMcpScheduleTaskInput,
9393 success: OrchestratorMcpScheduleTaskResult,
9494 failure: OrchestratorMcpFailure,
@@ -138,7 +138,7 @@ export const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { · 43 unmodified lines
138138
139139 export const CreateThreadsTool = Tool.make("create_threads", {
140140 description:
141− "Create one or more ordinary top-level T3 V2 threads. Each entry may have its own prompt, title, provider instance or driver, model, model options (e.g. reasoning effort), runtime mode, and interaction mode. Omitted provider/model/settings inherit from the calling thread; entries without prompts create empty threads.",
141+ "Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. If the user asks for agents, subagents, workers, delegation, or parallel help, call delegate_task once per child instead—even when selecting different providers. Use create_threads only when the user explicitly asks for separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit.",
142142 parameters: OrchestratorMcpCreateThreadsInput,
143143 success: OrchestratorMcpCreateThreadsResult,
144144 failure: OrchestratorMcpFailure,
@@ -151,7 +151,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { · 6 unmodified lines
151151
152152 export const ThreadStartTool = Tool.make("t3_thread_start", {
153153 description:
154− "Create an ordinary top-level T3 thread and immediately start its first turn. The new thread inherits this thread's project, checkout, provider, model, and runtime settings unless overridden. Use t3_thread_wait and t3_thread_read to collect its result.",
154+ "Create an ordinary TOP-LEVEL T3 conversation and immediately start its first turn. This is not a child agent/subagent; use delegate_task for delegated work. The new thread inherits this thread's project, checkout, provider, model, and runtime settings unless overridden. Use t3_thread_wait and t3_thread_read to collect its result.",
155155 parameters: OrchestratorMcpThreadStartInput,
156156 success: OrchestratorMcpCreatedThread,
157157 failure: OrchestratorMcpFailure,
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+8−2
@@ -55,6 +55,7 @@ import type { · 54 unmodified lines
5555 AcpSessionRuntimeStartResult,
5656 } from "../../provider/acp/AcpSessionRuntime.ts";
5757 import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
58+import { t3OrchestrationPromptForFirstRun } from "../../provider/T3OrchestrationInstructions.ts";
5859 import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
5960 import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
6061 import { makeProviderFailure } from "../ProviderFailure.ts";
@@ -4227,8 +4228,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4166 unmodified lines
42274228 turnInput: ProviderAdapterV2TurnInput,
42284229 ) {
42294230 const prompt: Array<EffectAcpSchema.ContentBlock> = [];
4230− if (turnInput.message.text.length > 0) {
4231− prompt.push({ type: "text", text: turnInput.message.text });
4231+ const text = t3OrchestrationPromptForFirstRun({
4232+ prompt: turnInput.message.text,
4233+ runOrdinal: turnInput.runOrdinal,
4234+ hasT3Mcp: acpMcpServers(turnInput.threadId).length > 0,
4235+ });
4236+ if (text.length > 0) {
4237+ prompt.push({ type: "text", text });
42324238 }
42334239 if (turnInput.message.attachments.length > 0 && !supportsImagePrompts) {
42344240 return yield* new ProviderAdapterProtocolError({
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+9−0
@@ -519,6 +519,15 @@ describe("ClaudeAdapterV2 native protocol logging", () => { · 518 unmodified lines
519519 cwd: "/workspace",
520520 ...overrides,
521521 });
522+ assert.isObject(options.systemPrompt);
523+ const systemPrompt = options.systemPrompt as {
524+ readonly type: string;
525+ readonly preset: string;
526+ readonly append?: string;
527+ };
528+ assert.equal(systemPrompt.type, "preset");
529+ assert.equal(systemPrompt.preset, "claude_code");
530+ assert.include(systemPrompt.append ?? "", "use `delegate_task`");
522531 const logged = loggedClaudeQueryOptions(options);
523532 assert.equal(logged.hasMcpServers, true);
524533 assert.notInclude(JSON.stringify(logged), "secret-claude-token");
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+10−0
@@ -67,6 +67,7 @@ import { · 66 unmodified lines
6767 makeEventNdjsonLogger,
6868 } from "../../provider/Layers/EventNdjsonLogger.ts";
6969 import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
70+import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts";
7071 import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
7172 import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
7273 import { makeProviderFailure } from "../ProviderFailure.ts";
@@ -688,6 +689,15 @@ export function makeClaudeQueryOptions(input: { · 615 unmodified lines
688689 : {}),
689690 ...(input.environment === undefined ? {} : { env: input.environment }),
690691 ...(input.mcpServers === undefined ? {} : { mcpServers: input.mcpServers }),
692+ ...(input.mcpServers === undefined
693+ ? {}
694+ : {
695+ systemPrompt: {
696+ type: "preset" as const,
697+ preset: "claude_code" as const,
698+ append: T3_CODE_ORCHESTRATION_INSTRUCTIONS,
699+ },
700+ }),
691701 ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }),
692702 };
693703 return input.cwd === null ? options : { ...options, cwd: input.cwd };
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+29−0
@@ -244,6 +244,35 @@ describe("CodexAdapterV2 runtime policy", () => { · 243 unmodified lines
244244 }),
245245 );
246246
247+ it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () =>
248+ Effect.gen(function* () {
249+ const params = yield* buildCodexTurnStartParams({
250+ nativeThreadId: "native-orchestration-instructions",
251+ codexInput: [{ type: "text", text: "delegate this task" }],
252+ runtimePolicy: {
253+ runtimeMode: "full-access",
254+ interactionMode: "default",
255+ cwd: null,
256+ },
257+ modelSelection: {
258+ instanceId: ProviderInstanceId.make("codex"),
259+ model: "gpt-5.4",
260+ },
261+ hasT3Mcp: true,
262+ });
263+
264+ assert.equal(params.collaborationMode?.mode, "default");
265+ assert.include(
266+ params.collaborationMode?.settings.developer_instructions ?? "",
267+ "use `delegate_task`",
268+ );
269+ assert.include(
270+ params.collaborationMode?.settings.developer_instructions ?? "",
271+ "structured object, never as JSON text",
272+ );
273+ }),
274+ );
275+
247276 it.effect("compiles per-turn Codex model options and cwd from their owning inputs", () =>
248277 Effect.gen(function* () {
249278 const params = yield* buildCodexTurnStartParams({
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+19−7
@@ -47,7 +47,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; · 46 unmodified lines
4747 import { resolveAttachmentPath } from "../../attachmentStore.ts";
4848 import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";
4949 import { ServerConfig } from "../../config.ts";
50−import { CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS } from "../../provider/CodexDeveloperInstructions.ts";
50+import {
51+ CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
52+ CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
53+} from "../../provider/CodexDeveloperInstructions.ts";
5154 import {
5255 materializeCodexShadowHome,
5356 resolveCodexHomeLayout,
@@ -542,6 +545,7 @@ export function buildCodexTurnStartParams(input: { · 488 unmodified lines
542545 readonly codexInput: ReadonlyArray<CodexSchema.V2TurnStartParams__UserInput>;
543546 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
544547 readonly modelSelection: ModelSelection;
548+ readonly hasT3Mcp?: boolean;
545549 }) {
546550 return Effect.gen(function* () {
547551 const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode);
@@ -560,17 +564,23 @@ export function buildCodexTurnStartParams(input: { · 12 unmodified lines
560564 const effort =
561565 selectedEffort === undefined ? undefined : yield* decodeTurnReasoningEffort(selectedEffort);
562566 const serviceTier = getCodexServiceTierOptionValue(input.modelSelection);
563− const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined =
567+ const developerInstructions =
564568 input.runtimePolicy.interactionMode === "plan"
565− ? {
566− mode: "plan",
569+ ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
570+ : input.hasT3Mcp === true
571+ ? CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS
572+ : undefined;
573+ const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined =
574+ developerInstructions === undefined
575+ ? undefined
576+ : {
577+ mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "default",
567578 settings: {
568579 model: input.modelSelection.model,
569580 reasoning_effort: effort ?? "medium",
570− developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
581+ developer_instructions: developerInstructions,
571582 },
572− }
573− : undefined;
583+ };
574584
575585 return yield* decodeCodexTurnStartParamsWithCollaborationMode({
576586 threadId: input.nativeThreadId,
@@ -4421,6 +4431,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi · 3844 unmodified lines
44214431 codexInput,
44224432 runtimePolicy: turnInput.runtimePolicy,
44234433 modelSelection: turnInput.modelSelection,
4434+ hasT3Mcp:
4435+ McpProviderSession.readMcpProviderSession(turnInput.threadId) !== undefined,
44244436 });
44254437 yield* Ref.update(pendingRootTurns, (current) => {
44264438 const updated = new Map(current);
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts+8−2
@@ -43,6 +43,7 @@ import { ServerConfig } from "../../config.ts"; · 42 unmodified lines
4343 import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
4444 import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts";
4545 import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
46+import { t3OrchestrationPromptForFirstRun } from "../../provider/T3OrchestrationInstructions.ts";
4647 import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
4748 import { makeProviderFailure } from "../ProviderFailure.ts";
4849 import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts";
@@ -2008,6 +2009,11 @@ export function makeCursorAdapterV2( · 1959 unmodified lines
20082009 const resolveUserMessage = Effect.fnUntraced(function* (
20092010 turnInput: ProviderAdapterV2TurnInput,
20102011 ) {
2012+ const text = t3OrchestrationPromptForFirstRun({
2013+ prompt: turnInput.message.text,
2014+ runOrdinal: turnInput.runOrdinal,
2015+ hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined,
2016+ });
20112017 const images = yield* Effect.forEach(
20122018 turnInput.message.attachments,
20132019 (attachment: ChatAttachment) =>
@@ -2046,9 +2052,9 @@ export function makeCursorAdapterV2( · 32 unmodified lines
20462052 });
20472053 }
20482054 return images.length === 0
2049− ? turnInput.message.text
2055+ ? text
20502056 : ({
2051− text: turnInput.message.text,
2057+ text,
20522058 images,
20532059 } satisfies SDKUserMessage);
20542060 });
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+10−1
@@ -59,6 +59,7 @@ import { · 58 unmodified lines
5959 summarizeNativeProtocolPayload,
6060 } from "../../provider/NativeProtocolLogging.ts";
6161 import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
62+import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts";
6263 import {
6364 OpenCodeRuntime,
6465 OpenCodeRuntimeError,
@@ -800,7 +801,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 735 unmodified lines
800801 });
801802
802803 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
803− if (mcpSession !== undefined && !connection.external) {
804+ const hasT3Mcp = mcpSession !== undefined && !connection.external;
805+ const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp);
806+ if (hasT3Mcp) {
804807 yield* runOpenCodeSdk("mcp.add", () =>
805808 client.mcp.add({
806809 name: "t3-code",
@@ -2493,6 +2496,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 1686 unmodified lines
24932496 model: parsedModel,
24942497 ...(agent === undefined ? {} : { agent }),
24952498 ...(variant === undefined ? {} : { variant }),
2499+ ...(orchestrationSystemPrompt === undefined
2500+ ? {}
2501+ : { system: orchestrationSystemPrompt }),
24962502 parts,
24972503 },
24982504 () =>
@@ -2501,6 +2507,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 2 unmodified lines
25012507 model: parsedModel,
25022508 ...(agent === undefined ? {} : { agent }),
25032509 ...(variant === undefined ? {} : { variant }),
2510+ ...(orchestrationSystemPrompt === undefined
2511+ ? {}
2512+ : { system: orchestrationSystemPrompt }),
25042513 parts,
25052514 }),
25062515 ).pipe(
apps/server/src/provider/CodexDeveloperInstructions.ts+5−10
@@ -1,6 +1,10 @@
11 import type { ProviderInteractionMode } from "@t3tools/contracts";
22 import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
33
4+import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "./T3OrchestrationInstructions.ts";
5+
6+import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "./T3OrchestrationInstructions.ts";
7+
48 const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `
59
610 ## T3 Code collaborative browser
@@ -12,15 +16,6 @@ For browser work, first call \`preview_status\`. If no automation-capable previe · 5 unmodified lines
1216 Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.
1317 `;
1418
15−const T3_CODE_THREAD_ORCHESTRATION_INSTRUCTIONS = `
16−
17−## T3 Code thread orchestration
18−
19−When the \`t3-code\` MCP server exposes \`t3_thread_*\` tools, you can run a bounded orchestration loop without asking the user to relay updates. Start independent work with \`t3_thread_start\` (or \`create_threads\` for a batch), retain the returned thread/run IDs, wait with \`t3_thread_wait\`, and collect durable output with \`t3_thread_read\`. Use \`t3_thread_send\` for follow-up or steering and \`t3_thread_interrupt\` when work is no longer needed. Use stable \`clientRequestId\` values when retrying mutating calls, and use \`t3_thread_list\` to recover IDs after context loss.
20−
21−Keep loops bounded by an explicit completion condition and timeout. A wait timeout does not stop the target thread. Do not repeatedly start equivalent threads or send duplicate work when a durable run is already active.
22−`;
23−
2419 /**
2520 * The browser block is omitted entirely when the preview tools aren't attached.
2621 * Describing `preview_*` tools that aren't in the turn's tool list would be
@@ -178,7 +173,7 @@ Use the \`request_user_input\` tool only when it is listed in the available tool · 151 unmodified lines
178173
179174 In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
180175 ${browserToolInstructions(browserToolsAvailable)}
181−${T3_CODE_THREAD_ORCHESTRATION_INSTRUCTIONS}
176+${T3_CODE_ORCHESTRATION_INSTRUCTIONS}
182177 </collaboration_mode>`;
183178
184179 export interface CodexRuntimeInfo {
apps/server/src/provider/T3OrchestrationInstructions.test.ts+47−0
@@ -0,0 +1,47 @@
1+import { assert, describe, it } from "@effect/vitest";
2+
3+import {
4+ T3_CODE_ORCHESTRATION_INSTRUCTIONS,
5+ t3OrchestrationPromptForFirstRun,
6+ t3OrchestrationSystemPrompt,
7+} from "./T3OrchestrationInstructions.ts";
8+
9+describe("T3 orchestration provider instructions", () => {
10+ it("distinguishes delegated subagents from ordinary top-level threads", () => {
11+ assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "use `delegate_task`");
12+ assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "ordinary top-level T3 conversations");
13+ assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Never use them merely");
14+ assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "different provider");
15+ });
16+
17+ it("documents structured schedules instead of JSON strings", () => {
18+ assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "structured object, never as JSON text");
19+ assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, '"everyMs":3600000');
20+ assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "bindToCurrentThread=false");
21+ });
22+
23+ it("injects prompt fallback only for an MCP-enabled first run", () => {
24+ const prompt = "Inspect the repository.";
25+ const injected = t3OrchestrationPromptForFirstRun({
26+ prompt,
27+ runOrdinal: 1,
28+ hasT3Mcp: true,
29+ });
30+
31+ assert.include(injected, "<t3_code_orchestration_instructions>");
32+ assert.include(injected, `<user_request>\n${prompt}\n</user_request>`);
33+ assert.equal(
34+ t3OrchestrationPromptForFirstRun({ prompt, runOrdinal: 2, hasT3Mcp: true }),
35+ prompt,
36+ );
37+ assert.equal(
38+ t3OrchestrationPromptForFirstRun({ prompt, runOrdinal: 1, hasT3Mcp: false }),
39+ prompt,
40+ );
41+ });
42+
43+ it("only exposes the system prompt when the T3 MCP server is attached", () => {
44+ assert.equal(t3OrchestrationSystemPrompt(false), undefined);
45+ assert.equal(t3OrchestrationSystemPrompt(true), T3_CODE_ORCHESTRATION_INSTRUCTIONS);
46+ });
47+});
apps/server/src/provider/T3OrchestrationInstructions.ts+35−0
@@ -0,0 +1,35 @@
1+export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = `
2+
3+## T3 Code orchestration
4+
5+The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly:
6+
7+- A delegated task/subagent is child work owned by the current thread. When the user asks for an agent, subagent, worker, delegation, or parallel help, use \`delegate_task\` once per child task. This remains true when targeting a different provider. Use \`orchestrator_capabilities\` to discover provider/model IDs, retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation.
8+- \`create_threads\` and \`t3_thread_start\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work.
9+- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time.
10+
11+Tool names may include an MCP prefix (for example \`mcp__t3-code__delegate_task\`); the semantics are the same. Keep polling/wait loops bounded, do not duplicate active work, and use stable \`clientRequestId\` values when retrying mutations.
12+`;
13+
14+/**
15+ * Providers without a system/developer-instruction channel receive this
16+ * context in the first prompt. Keep the wrapper explicit so it cannot be
17+ * mistaken for text authored by the user.
18+ */
19+export function prependT3OrchestrationInstructions(prompt: string): string {
20+ return `<t3_code_orchestration_instructions>${T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()}</t3_code_orchestration_instructions>\n\n<user_request>\n${prompt}\n</user_request>`;
21+}
22+
23+export function t3OrchestrationPromptForFirstRun(input: {
24+ readonly prompt: string;
25+ readonly runOrdinal: number;
26+ readonly hasT3Mcp: boolean;
27+}): string {
28+ return input.runOrdinal === 1 && input.hasT3Mcp
29+ ? prependT3OrchestrationInstructions(input.prompt)
30+ : input.prompt;
31+}
32+
33+export function t3OrchestrationSystemPrompt(hasT3Mcp: boolean): string | undefined {
34+ return hasT3Mcp ? T3_CODE_ORCHESTRATION_INSTRUCTIONS : undefined;
35+}
packages/contracts/src/orchestratorMcp.ts+66−13
@@ -31,9 +31,35 @@ import { · 30 unmodified lines
3131 } from "./model.ts";
3232 import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
3333
34−const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000));
35−const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512));
36−const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));
34+const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({
35+ description: "Complete task or message text for the target agent.",
36+});
37+const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({
38+ description: "Optional concise display title.",
39+});
40+const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(
41+ Schema.isMaxLength(256),
42+).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." });
43+
44+/**
45+ * OpenCode 1.15 has been observed serializing nested MCP union objects as JSON
46+ * strings. Keep the structured object as the documented form while accepting
47+ * that compatibility shape at the tool boundary and decoding it immediately.
48+ */
49+const OrchestratorMcpScheduleFromJsonString = Schema.fromJsonString(ScheduledTaskSchedule).annotate(
50+ {
51+ description:
52+ "Compatibility-only JSON encoding of the schedule object. Prefer a structured object.",
53+ },
54+);
55+
56+const OrchestratorMcpSchedule = Schema.Union([
57+ ScheduledTaskSchedule,
58+ OrchestratorMcpScheduleFromJsonString,
59+]).annotate({
60+ description:
61+ "Recurring schedule object: {type:'interval', everyMs} or {type:'fixed_time', timeOfDay, weekdays?}. Never stringify it unless the provider requires the compatibility form.",
62+});
3763
3864 /**
3965 * Shorthand `{ id: value }` record form for target model options. Unlike the
@@ -65,9 +91,21 @@ export const OrchestratorMcpTargetOptions = Schema.Union([ · 25 unmodified lines
6591 export type OrchestratorMcpTargetOptions = typeof OrchestratorMcpTargetOptions.Type;
6692
6793 export const OrchestratorMcpTarget = Schema.Struct({
68− providerInstanceId: Schema.optional(ProviderInstanceId),
69− driverKind: Schema.optional(ProviderDriverKind),
70− model: Schema.optional(TrimmedNonEmptyString),
94+ providerInstanceId: Schema.optional(
95+ ProviderInstanceId.annotate({
96+ description: "Configured provider instance id from orchestrator_capabilities.",
97+ }),
98+ ),
99+ driverKind: Schema.optional(
100+ ProviderDriverKind.annotate({
101+ description: "Provider driver kind; prefer providerInstanceId when available.",
102+ }),
103+ ),
104+ model: Schema.optional(
105+ TrimmedNonEmptyString.annotate({
106+ description: "Model id advertised for the selected provider instance.",
107+ }),
108+ ),
71109 /**
72110 * Model option selections for the child (for example reasoning effort).
73111 * Accepts the canonical `[{ id, value }]` array or the shorthand
@@ -75,7 +113,11 @@ export const OrchestratorMcpTarget = Schema.Struct({ · 1 unmodified line
75113 * advertised by orchestrator_capabilities. When omitted, options inherit
76114 * from the parent only when the child runs the parent's provider and model.
77115 */
78− options: Schema.optional(OrchestratorMcpTargetOptions),
116+ options: Schema.optional(
117+ OrchestratorMcpTargetOptions.annotate({
118+ description: "Model option selections advertised by orchestrator_capabilities.",
119+ }),
120+ ),
79121 });
80122 export type OrchestratorMcpTarget = typeof OrchestratorMcpTarget.Type;
81123
@@ -110,7 +152,9 @@ export const OrchestratorMcpDelegatedTaskStatus = Schema.Literals([ · 28 unmodified lines
110152 export type OrchestratorMcpDelegatedTaskStatus = typeof OrchestratorMcpDelegatedTaskStatus.Type;
111153
112154 export const OrchestratorMcpDelegateTaskInput = Schema.Struct({
113− task: OrchestratorMcpPrompt,
155+ task: OrchestratorMcpPrompt.annotate({
156+ description: "Self-contained task for one delegated child agent/subagent.",
157+ }),
114158 target: Schema.optional(OrchestratorMcpTarget),
115159 title: Schema.optional(OrchestratorMcpTitle),
116160 role: Schema.optional(OrchestratorMcpTaskRole),
@@ -418,16 +462,25 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ · 301 unmodified lines
418462 export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type;
419463
420464 export const OrchestratorMcpScheduleTaskInput = Schema.Struct({
421− prompt: OrchestratorMcpPrompt,
422− schedule: ScheduledTaskSchedule,
465+ prompt: OrchestratorMcpPrompt.annotate({
466+ description: "Prompt executed on every scheduled run.",
467+ }),
468+ schedule: OrchestratorMcpSchedule,
423469 title: Schema.optional(OrchestratorMcpTitle),
424− enabled: Schema.optional(Schema.Boolean),
470+ enabled: Schema.optional(
471+ Schema.Boolean.annotate({ description: "Whether the schedule starts enabled; defaults true." }),
472+ ),
425473 /**
426474 * When true (the default), the scheduled task fires into the calling thread
427475 * on each run instead of launching a fresh thread. This is the recurring
428476 * "wake up in this thread" behaviour reserved for agent-created tasks.
429477 */
430− bindToCurrentThread: Schema.optional(Schema.Boolean),
478+ bindToCurrentThread: Schema.optional(
479+ Schema.Boolean.annotate({
480+ description:
481+ "True (default) posts each run into this thread; false creates a fresh top-level thread per run.",
482+ }),
483+ ),
431484 clientRequestId: Schema.optional(OrchestratorMcpClientRequestId),
432485 });
433486 export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type;
@@ -459,7 +512,7 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ · 25 unmodified lines
459512 scheduledTaskId: ScheduledTaskId,
460513 prompt: Schema.optional(OrchestratorMcpPrompt),
461514 title: Schema.optional(OrchestratorMcpTitle),
462− schedule: Schema.optional(ScheduledTaskSchedule),
515+ schedule: Schema.optional(OrchestratorMcpSchedule),
463516 enabled: Schema.optional(Schema.Boolean),
464517 bindToCurrentThread: Schema.optional(Schema.Boolean),
465518 });
packages/contracts/src/scheduledTask.ts+27−6
@@ -18,21 +18,42 @@ import { · 17 unmodified lines
1818 import { ProviderInteractionMode, RuntimeMode } from "./providerPolicy.ts";
1919
2020 /** 24-hour "HH:MM" wall-clock time. Mirrors `parseTimeOfDay` on the server. */
21−const TimeOfDay = TrimmedNonEmptyString.check(Schema.isPattern(/^([01]?\d|2[0-3]):([0-5]\d)$/));
21+const TimeOfDay = TrimmedNonEmptyString.check(
22+ Schema.isPattern(/^([01]?\d|2[0-3]):([0-5]\d)$/),
23+).annotate({ description: "Local wall-clock time in 24-hour HH:MM form, such as 09:30." });
2224
2325 export const ScheduledTaskSchedule = Schema.Union([
2426 Schema.Struct({
25− type: Schema.Literal("interval"),
26− everyMs: PositiveInt,
27+ type: Schema.Literal("interval").annotate({
28+ description: "Select interval scheduling.",
29+ }),
30+ everyMs: PositiveInt.annotate({
31+ description: "Positive interval in milliseconds; 3600000 means every hour.",
32+ }),
33+ }).annotate({
34+ description: "Run repeatedly after a fixed number of milliseconds.",
2735 }),
2836 Schema.Struct({
29− type: Schema.Literal("fixed_time"),
37+ type: Schema.Literal("fixed_time").annotate({
38+ description: "Select a fixed local wall-clock time.",
39+ }),
3040 timeOfDay: TimeOfDay,
3141 weekdays: Schema.optional(
32− Schema.Array(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 6 }))),
42+ Schema.Array(
43+ Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 6 })).annotate({
44+ description: "Weekday number where 0 is Sunday and 6 is Saturday.",
45+ }),
46+ ).annotate({
47+ description: "Optional weekdays; omit to run every day.",
48+ }),
3349 ),
50+ }).annotate({
51+ description: "Run at a fixed local wall-clock time on selected weekdays.",
3452 }),
35−]);
53+]).annotate({
54+ description:
55+ "Structured recurring schedule. Pass an object with type 'interval' or 'fixed_time'.",
56+});
3657 export type ScheduledTaskSchedule = typeof ScheduledTaskSchedule.Type;
3758
3859 export const ScheduledTaskRunStatus = Schema.Literals(["never", "running", "succeeded", "failed"]);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t