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
668 });668 });
669 expect(yield* Ref.get(scheduledStore)).toHaveLength(0);669 expect(yield* Ref.get(scheduledStore)).toHaveLength(0);
670 670
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
671 const delegatedCall = yield* invoke("delegate_task", {692 const delegatedCall = yield* invoke("delegate_task", {
672 task: delegatedPrompt,693 task: delegatedPrompt,
673 target: {694 target: {
apps/server/src/mcp/toolkits/orchestrator/tools.test.ts+28−0
@@ -0,0 +1,28 @@
1import { assert, describe, it } from "@effect/vitest";
2import { Tool } from "effect/unstable/ai";
3
4import { CreateThreadsTool, DelegateTaskTool, ScheduleTaskTool } from "./tools.ts";
5
6describe("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
49 49
50export const DelegateTaskTool = Tool.make("delegate_task", {50export const DelegateTaskTool = Tool.make("delegate_task", {
51 description:51 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.",
53 parameters: OrchestratorMcpDelegateTaskInput,53 parameters: OrchestratorMcpDelegateTaskInput,
54 success: OrchestratorMcpDelegateTaskResult,54 success: OrchestratorMcpDelegateTaskResult,
55 failure: OrchestratorMcpFailure,55 failure: OrchestratorMcpFailure,
@@ -88,7 +88,7 @@ export const TaskCancelTool = Tool.make("task_cancel", { · 32 unmodified lines
88 88
89export const ScheduleTaskTool = Tool.make("schedule_task", {89export const ScheduleTaskTool = Tool.make("schedule_task", {
90 description:90 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.",
92 parameters: OrchestratorMcpScheduleTaskInput,92 parameters: OrchestratorMcpScheduleTaskInput,
93 success: OrchestratorMcpScheduleTaskResult,93 success: OrchestratorMcpScheduleTaskResult,
94 failure: OrchestratorMcpFailure,94 failure: OrchestratorMcpFailure,
@@ -138,7 +138,7 @@ export const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { · 43 unmodified lines
138 138
139export const CreateThreadsTool = Tool.make("create_threads", {139export const CreateThreadsTool = Tool.make("create_threads", {
140 description:140 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.",
142 parameters: OrchestratorMcpCreateThreadsInput,142 parameters: OrchestratorMcpCreateThreadsInput,
143 success: OrchestratorMcpCreateThreadsResult,143 success: OrchestratorMcpCreateThreadsResult,
144 failure: OrchestratorMcpFailure,144 failure: OrchestratorMcpFailure,
@@ -151,7 +151,7 @@ export const CreateThreadsTool = Tool.make("create_threads", { · 6 unmodified lines
151 151
152export const ThreadStartTool = Tool.make("t3_thread_start", {152export const ThreadStartTool = Tool.make("t3_thread_start", {
153 description:153 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.",
155 parameters: OrchestratorMcpThreadStartInput,155 parameters: OrchestratorMcpThreadStartInput,
156 success: OrchestratorMcpCreatedThread,156 success: OrchestratorMcpCreatedThread,
157 failure: OrchestratorMcpFailure,157 failure: OrchestratorMcpFailure,
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+8−2
@@ -55,6 +55,7 @@ import type { · 54 unmodified lines
55 AcpSessionRuntimeStartResult,55 AcpSessionRuntimeStartResult,
56} from "../../provider/acp/AcpSessionRuntime.ts";56} from "../../provider/acp/AcpSessionRuntime.ts";
57import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";57import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
58import { t3OrchestrationPromptForFirstRun } from "../../provider/T3OrchestrationInstructions.ts";
58import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";59import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
59import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";60import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
60import { makeProviderFailure } from "../ProviderFailure.ts";61import { makeProviderFailure } from "../ProviderFailure.ts";
@@ -4227,8 +4228,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4166 unmodified lines
4227 turnInput: ProviderAdapterV2TurnInput,4228 turnInput: ProviderAdapterV2TurnInput,
4228 ) {4229 ) {
4229 const prompt: Array<EffectAcpSchema.ContentBlock> = [];4230 const prompt: Array<EffectAcpSchema.ContentBlock> = [];
4230 if (turnInput.message.text.length > 0) {4231 const text = t3OrchestrationPromptForFirstRun({
4231 prompt.push({ type: "text", text: turnInput.message.text });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 });
4232 }4238 }
4233 if (turnInput.message.attachments.length > 0 && !supportsImagePrompts) {4239 if (turnInput.message.attachments.length > 0 && !supportsImagePrompts) {
4234 return yield* new ProviderAdapterProtocolError({4240 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
519 cwd: "/workspace",519 cwd: "/workspace",
520 ...overrides,520 ...overrides,
521 });521 });
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`");
522 const logged = loggedClaudeQueryOptions(options);531 const logged = loggedClaudeQueryOptions(options);
523 assert.equal(logged.hasMcpServers, true);532 assert.equal(logged.hasMcpServers, true);
524 assert.notInclude(JSON.stringify(logged), "secret-claude-token");533 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
67 makeEventNdjsonLogger,67 makeEventNdjsonLogger,
68} from "../../provider/Layers/EventNdjsonLogger.ts";68} from "../../provider/Layers/EventNdjsonLogger.ts";
69import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";69import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
70import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts";
70import * as McpProviderSession from "../../mcp/McpProviderSession.ts";71import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
71import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";72import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
72import { makeProviderFailure } from "../ProviderFailure.ts";73import { makeProviderFailure } from "../ProviderFailure.ts";
@@ -688,6 +689,15 @@ export function makeClaudeQueryOptions(input: { · 615 unmodified lines
688 : {}),689 : {}),
689 ...(input.environment === undefined ? {} : { env: input.environment }),690 ...(input.environment === undefined ? {} : { env: input.environment }),
690 ...(input.mcpServers === undefined ? {} : { mcpServers: input.mcpServers }),691 ...(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 }),
691 ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }),701 ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }),
692 };702 };
693 return input.cwd === null ? options : { ...options, cwd: input.cwd };703 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
244 }),244 }),
245 );245 );
246 246
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
247 it.effect("compiles per-turn Codex model options and cwd from their owning inputs", () =>276 it.effect("compiles per-turn Codex model options and cwd from their owning inputs", () =>
248 Effect.gen(function* () {277 Effect.gen(function* () {
249 const params = yield* buildCodexTurnStartParams({278 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
47import { resolveAttachmentPath } from "../../attachmentStore.ts";47import { resolveAttachmentPath } from "../../attachmentStore.ts";
48import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";48import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";
49import { ServerConfig } from "../../config.ts";49import { ServerConfig } from "../../config.ts";
50import { CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS } from "../../provider/CodexDeveloperInstructions.ts";50import {
51 CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
52 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
53} from "../../provider/CodexDeveloperInstructions.ts";
51import {54import {
52 materializeCodexShadowHome,55 materializeCodexShadowHome,
53 resolveCodexHomeLayout,56 resolveCodexHomeLayout,
@@ -542,6 +545,7 @@ export function buildCodexTurnStartParams(input: { · 488 unmodified lines
542 readonly codexInput: ReadonlyArray<CodexSchema.V2TurnStartParams__UserInput>;545 readonly codexInput: ReadonlyArray<CodexSchema.V2TurnStartParams__UserInput>;
543 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;546 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
544 readonly modelSelection: ModelSelection;547 readonly modelSelection: ModelSelection;
548 readonly hasT3Mcp?: boolean;
545}) {549}) {
546 return Effect.gen(function* () {550 return Effect.gen(function* () {
547 const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode);551 const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode);
@@ -560,17 +564,23 @@ export function buildCodexTurnStartParams(input: { · 12 unmodified lines
560 const effort =564 const effort =
561 selectedEffort === undefined ? undefined : yield* decodeTurnReasoningEffort(selectedEffort);565 selectedEffort === undefined ? undefined : yield* decodeTurnReasoningEffort(selectedEffort);
562 const serviceTier = getCodexServiceTierOptionValue(input.modelSelection);566 const serviceTier = getCodexServiceTierOptionValue(input.modelSelection);
563 const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined =567 const developerInstructions =
564 input.runtimePolicy.interactionMode === "plan"568 input.runtimePolicy.interactionMode === "plan"
565 ? {569 ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
566 mode: "plan",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",
567 settings: {578 settings: {
568 model: input.modelSelection.model,579 model: input.modelSelection.model,
569 reasoning_effort: effort ?? "medium",580 reasoning_effort: effort ?? "medium",
570 developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,581 developer_instructions: developerInstructions,
571 },582 },
572 }583 };
573 : undefined;
574 584
575 return yield* decodeCodexTurnStartParamsWithCollaborationMode({585 return yield* decodeCodexTurnStartParamsWithCollaborationMode({
576 threadId: input.nativeThreadId,586 threadId: input.nativeThreadId,
@@ -4421,6 +4431,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi · 3844 unmodified lines
4421 codexInput,4431 codexInput,
4422 runtimePolicy: turnInput.runtimePolicy,4432 runtimePolicy: turnInput.runtimePolicy,
4423 modelSelection: turnInput.modelSelection,4433 modelSelection: turnInput.modelSelection,
4434 hasT3Mcp:
4435 McpProviderSession.readMcpProviderSession(turnInput.threadId) !== undefined,
4424 });4436 });
4425 yield* Ref.update(pendingRootTurns, (current) => {4437 yield* Ref.update(pendingRootTurns, (current) => {
4426 const updated = new Map(current);4438 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
43import * as McpProviderSession from "../../mcp/McpProviderSession.ts";43import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
44import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts";44import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts";
45import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";45import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
46import { t3OrchestrationPromptForFirstRun } from "../../provider/T3OrchestrationInstructions.ts";
46import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";47import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
47import { makeProviderFailure } from "../ProviderFailure.ts";48import { makeProviderFailure } from "../ProviderFailure.ts";
48import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts";49import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts";
@@ -2008,6 +2009,11 @@ export function makeCursorAdapterV2( · 1959 unmodified lines
2008 const resolveUserMessage = Effect.fnUntraced(function* (2009 const resolveUserMessage = Effect.fnUntraced(function* (
2009 turnInput: ProviderAdapterV2TurnInput,2010 turnInput: ProviderAdapterV2TurnInput,
2010 ) {2011 ) {
2012 const text = t3OrchestrationPromptForFirstRun({
2013 prompt: turnInput.message.text,
2014 runOrdinal: turnInput.runOrdinal,
2015 hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined,
2016 });
2011 const images = yield* Effect.forEach(2017 const images = yield* Effect.forEach(
2012 turnInput.message.attachments,2018 turnInput.message.attachments,
2013 (attachment: ChatAttachment) =>2019 (attachment: ChatAttachment) =>
@@ -2046,9 +2052,9 @@ export function makeCursorAdapterV2( · 32 unmodified lines
2046 });2052 });
2047 }2053 }
2048 return images.length === 02054 return images.length === 0
2049 ? turnInput.message.text2055 ? text
2050 : ({2056 : ({
2051 text: turnInput.message.text,2057 text,
2052 images,2058 images,
2053 } satisfies SDKUserMessage);2059 } satisfies SDKUserMessage);
2054 });2060 });
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+10−1
@@ -59,6 +59,7 @@ import { · 58 unmodified lines
59 summarizeNativeProtocolPayload,59 summarizeNativeProtocolPayload,
60} from "../../provider/NativeProtocolLogging.ts";60} from "../../provider/NativeProtocolLogging.ts";
61import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";61import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
62import { t3OrchestrationSystemPrompt } from "../../provider/T3OrchestrationInstructions.ts";
62import {63import {
63 OpenCodeRuntime,64 OpenCodeRuntime,
64 OpenCodeRuntimeError,65 OpenCodeRuntimeError,
@@ -800,7 +801,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 735 unmodified lines
800 });801 });
801 802
802 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);803 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) {
804 yield* runOpenCodeSdk("mcp.add", () =>807 yield* runOpenCodeSdk("mcp.add", () =>
805 client.mcp.add({808 client.mcp.add({
806 name: "t3-code",809 name: "t3-code",
@@ -2493,6 +2496,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 1686 unmodified lines
2493 model: parsedModel,2496 model: parsedModel,
2494 ...(agent === undefined ? {} : { agent }),2497 ...(agent === undefined ? {} : { agent }),
2495 ...(variant === undefined ? {} : { variant }),2498 ...(variant === undefined ? {} : { variant }),
2499 ...(orchestrationSystemPrompt === undefined
2500 ? {}
2501 : { system: orchestrationSystemPrompt }),
2496 parts,2502 parts,
2497 },2503 },
2498 () =>2504 () =>
@@ -2501,6 +2507,9 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 2 unmodified lines
2501 model: parsedModel,2507 model: parsedModel,
2502 ...(agent === undefined ? {} : { agent }),2508 ...(agent === undefined ? {} : { agent }),
2503 ...(variant === undefined ? {} : { variant }),2509 ...(variant === undefined ? {} : { variant }),
2510 ...(orchestrationSystemPrompt === undefined
2511 ? {}
2512 : { system: orchestrationSystemPrompt }),
2504 parts,2513 parts,
2505 }),2514 }),
2506 ).pipe(2515 ).pipe(
apps/server/src/provider/CodexDeveloperInstructions.ts+5−10
@@ -1,6 +1,10 @@
1import type { ProviderInteractionMode } from "@t3tools/contracts";1import type { ProviderInteractionMode } from "@t3tools/contracts";
2import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";2import { buildRuntimeInstructions } from "./RuntimeInstructions.ts";
3 3
4import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "./T3OrchestrationInstructions.ts";
5
6import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "./T3OrchestrationInstructions.ts";
7
4const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `8const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `
5 9
6## T3 Code collaborative browser10## T3 Code collaborative browser
@@ -12,15 +16,6 @@ For browser work, first call \`preview_status\`. If no automation-capable previe · 5 unmodified lines
12Do 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.16Do 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.
13`;17`;
14 18
15const T3_CODE_THREAD_ORCHESTRATION_INSTRUCTIONS = `
16
17## T3 Code thread orchestration
18
19When 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
21Keep 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
24/**19/**
25 * The browser block is omitted entirely when the preview tools aren't attached.20 * The browser block is omitted entirely when the preview tools aren't attached.
26 * Describing `preview_*` tools that aren't in the turn's tool list would be21 * 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
178 173
179In 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.174In 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.
180${browserToolInstructions(browserToolsAvailable)}175${browserToolInstructions(browserToolsAvailable)}
181${T3_CODE_THREAD_ORCHESTRATION_INSTRUCTIONS}176${T3_CODE_ORCHESTRATION_INSTRUCTIONS}
182</collaboration_mode>`;177</collaboration_mode>`;
183 178
184export interface CodexRuntimeInfo {179export interface CodexRuntimeInfo {
apps/server/src/provider/T3OrchestrationInstructions.test.ts+47−0
@@ -0,0 +1,47 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import {
4 T3_CODE_ORCHESTRATION_INSTRUCTIONS,
5 t3OrchestrationPromptForFirstRun,
6 t3OrchestrationSystemPrompt,
7} from "./T3OrchestrationInstructions.ts";
8
9describe("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 @@
1export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = `
2
3## T3 Code orchestration
4
5The \`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
11Tool 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 */
19export 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
23export 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
33export 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
31} from "./model.ts";31} from "./model.ts";
32import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";32import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
33 33
34const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000));34const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({
35const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512));35 description: "Complete task or message text for the target agent.",
36const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256));36});
37const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({
38 description: "Optional concise display title.",
39});
40const 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 */
49const OrchestratorMcpScheduleFromJsonString = Schema.fromJsonString(ScheduledTaskSchedule).annotate(
50 {
51 description:
52 "Compatibility-only JSON encoding of the schedule object. Prefer a structured object.",
53 },
54);
55
56const 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});
37 63
38/**64/**
39 * Shorthand `{ id: value }` record form for target model options. Unlike the65 * Shorthand `{ id: value }` record form for target model options. Unlike the
@@ -65,9 +91,21 @@ export const OrchestratorMcpTargetOptions = Schema.Union([ · 25 unmodified lines
65export type OrchestratorMcpTargetOptions = typeof OrchestratorMcpTargetOptions.Type;91export type OrchestratorMcpTargetOptions = typeof OrchestratorMcpTargetOptions.Type;
66 92
67export const OrchestratorMcpTarget = Schema.Struct({93export const OrchestratorMcpTarget = Schema.Struct({
68 providerInstanceId: Schema.optional(ProviderInstanceId),94 providerInstanceId: Schema.optional(
69 driverKind: Schema.optional(ProviderDriverKind),95 ProviderInstanceId.annotate({
70 model: Schema.optional(TrimmedNonEmptyString),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 ),
71 /**109 /**
72 * Model option selections for the child (for example reasoning effort).110 * Model option selections for the child (for example reasoning effort).
73 * Accepts the canonical `[{ id, value }]` array or the shorthand111 * Accepts the canonical `[{ id, value }]` array or the shorthand
@@ -75,7 +113,11 @@ export const OrchestratorMcpTarget = Schema.Struct({ · 1 unmodified line
75 * advertised by orchestrator_capabilities. When omitted, options inherit113 * advertised by orchestrator_capabilities. When omitted, options inherit
76 * from the parent only when the child runs the parent's provider and model.114 * from the parent only when the child runs the parent's provider and model.
77 */115 */
78 options: Schema.optional(OrchestratorMcpTargetOptions),116 options: Schema.optional(
117 OrchestratorMcpTargetOptions.annotate({
118 description: "Model option selections advertised by orchestrator_capabilities.",
119 }),
120 ),
79});121});
80export type OrchestratorMcpTarget = typeof OrchestratorMcpTarget.Type;122export type OrchestratorMcpTarget = typeof OrchestratorMcpTarget.Type;
81 123
@@ -110,7 +152,9 @@ export const OrchestratorMcpDelegatedTaskStatus = Schema.Literals([ · 28 unmodified lines
110export type OrchestratorMcpDelegatedTaskStatus = typeof OrchestratorMcpDelegatedTaskStatus.Type;152export type OrchestratorMcpDelegatedTaskStatus = typeof OrchestratorMcpDelegatedTaskStatus.Type;
111 153
112export const OrchestratorMcpDelegateTaskInput = Schema.Struct({154export const OrchestratorMcpDelegateTaskInput = Schema.Struct({
113 task: OrchestratorMcpPrompt,155 task: OrchestratorMcpPrompt.annotate({
156 description: "Self-contained task for one delegated child agent/subagent.",
157 }),
114 target: Schema.optional(OrchestratorMcpTarget),158 target: Schema.optional(OrchestratorMcpTarget),
115 title: Schema.optional(OrchestratorMcpTitle),159 title: Schema.optional(OrchestratorMcpTitle),
116 role: Schema.optional(OrchestratorMcpTaskRole),160 role: Schema.optional(OrchestratorMcpTaskRole),
@@ -418,16 +462,25 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({ · 301 unmodified lines
418export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type;462export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type;
419 463
420export const OrchestratorMcpScheduleTaskInput = Schema.Struct({464export const OrchestratorMcpScheduleTaskInput = Schema.Struct({
421 prompt: OrchestratorMcpPrompt,465 prompt: OrchestratorMcpPrompt.annotate({
422 schedule: ScheduledTaskSchedule,466 description: "Prompt executed on every scheduled run.",
467 }),
468 schedule: OrchestratorMcpSchedule,
423 title: Schema.optional(OrchestratorMcpTitle),469 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 ),
425 /**473 /**
426 * When true (the default), the scheduled task fires into the calling thread474 * When true (the default), the scheduled task fires into the calling thread
427 * on each run instead of launching a fresh thread. This is the recurring475 * on each run instead of launching a fresh thread. This is the recurring
428 * "wake up in this thread" behaviour reserved for agent-created tasks.476 * "wake up in this thread" behaviour reserved for agent-created tasks.
429 */477 */
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 ),
431 clientRequestId: Schema.optional(OrchestratorMcpClientRequestId),484 clientRequestId: Schema.optional(OrchestratorMcpClientRequestId),
432});485});
433export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type;486export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type;
@@ -459,7 +512,7 @@ export const OrchestratorMcpUpdateScheduledTaskInput = Schema.Struct({ · 25 unmodified lines
459 scheduledTaskId: ScheduledTaskId,512 scheduledTaskId: ScheduledTaskId,
460 prompt: Schema.optional(OrchestratorMcpPrompt),513 prompt: Schema.optional(OrchestratorMcpPrompt),
461 title: Schema.optional(OrchestratorMcpTitle),514 title: Schema.optional(OrchestratorMcpTitle),
462 schedule: Schema.optional(ScheduledTaskSchedule),515 schedule: Schema.optional(OrchestratorMcpSchedule),
463 enabled: Schema.optional(Schema.Boolean),516 enabled: Schema.optional(Schema.Boolean),
464 bindToCurrentThread: Schema.optional(Schema.Boolean),517 bindToCurrentThread: Schema.optional(Schema.Boolean),
465});518});
packages/contracts/src/scheduledTask.ts+27−6
@@ -18,21 +18,42 @@ import { · 17 unmodified lines
18import { ProviderInteractionMode, RuntimeMode } from "./providerPolicy.ts";18import { ProviderInteractionMode, RuntimeMode } from "./providerPolicy.ts";
19 19
20/** 24-hour "HH:MM" wall-clock time. Mirrors `parseTimeOfDay` on the server. */20/** 24-hour "HH:MM" wall-clock time. Mirrors `parseTimeOfDay` on the server. */
21const TimeOfDay = TrimmedNonEmptyString.check(Schema.isPattern(/^([01]?\d|2[0-3]):([0-5]\d)$/));21const 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." });
22 24
23export const ScheduledTaskSchedule = Schema.Union([25export const ScheduledTaskSchedule = Schema.Union([
24 Schema.Struct({26 Schema.Struct({
25 type: Schema.Literal("interval"),27 type: Schema.Literal("interval").annotate({
26 everyMs: PositiveInt,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.",
27 }),35 }),
28 Schema.Struct({36 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 }),
30 timeOfDay: TimeOfDay,40 timeOfDay: TimeOfDay,
31 weekdays: Schema.optional(41 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 }),
33 ),49 ),
50 }).annotate({
51 description: "Run at a fixed local wall-clock time on selected weekdays.",
34 }),52 }),
35]);53]).annotate({
54 description:
55 "Structured recurring schedule. Pass an object with type 'interval' or 'fixed_time'.",
56});
36export type ScheduledTaskSchedule = typeof ScheduledTaskSchedule.Type;57export type ScheduledTaskSchedule = typeof ScheduledTaskSchedule.Type;
37 58
38export const ScheduledTaskRunStatus = Schema.Literals(["never", "running", "succeeded", "failed"]);59export 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