diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 5509d85fffdf..ce709fafe58c 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -1,7 +1,7 @@ import { type CommandId, type RuntimeRequestId, - type ThreadId, + ThreadId, type OrchestrationV2ThreadProjection, type RunId, OrchestratorMcpFailure, @@ -61,6 +61,55 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* ( return { ...context, request, item }; }); export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ + t3_thread_fork: (input) => + Effect.gen(function* () { + const { threads, projection } = yield* readWritableThread(); + const commandId = yield* newCommandId(); + const targetThreadId = ThreadId.make(`${commandId}:fork`); + const result = yield* threads + .dispatch({ + type: "thread.fork", + commandId, + sourceThreadId: projection.thread.id, + targetThreadId, + sourcePoint: input.sourcePoint, + ...(input.title === undefined ? {} : { title: input.title }), + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.mapError(unavailable)); + return { sequence: result.sequence, targetThreadId }; + }), + t3_thread_merge_back: (input) => + Effect.gen(function* () { + const { threads, caller } = yield* readWritableThread(input.targetThreadId); + const result = yield* threads + .dispatch({ + type: "thread.merge_back", + commandId: yield* newCommandId(), + sourceThreadId: caller.id, + targetThreadId: input.targetThreadId, + sourcePoint: input.sourcePoint, + createdBy: "agent", + creationSource: "mcp", + }) + .pipe(Effect.mapError(unavailable)); + return { sequence: result.sequence, targetThreadId: input.targetThreadId }; + }), + t3_thread_transfers: (input) => + Effect.gen(function* () { + const { projection } = yield* readThread(input.threadId); + return { + transfers: projection.contextTransfers.map( + ({ id, sourceThreadId, targetThreadId, status }) => ({ + id, + sourceThreadId, + targetThreadId, + status, + }), + ), + }; + }), t3_thread_configuration: (input) => Effect.gen(function* () { const { diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index a616dda39c98..4cc50ee9c60b 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -1,4 +1,7 @@ import { + OrchestrationV2ThreadForkSourcePoint, + OrchestrationV2ContextTransfer, + TrimmedNonEmptyString, ModelSelection, RuntimeMode, ProviderInteractionMode, @@ -174,7 +177,49 @@ export const ThreadConfigureTool = Tool.make("t3_thread_configure", { parameters: Schema.Struct({ modelSelection: ModelSelection }), }).annotate(Tool.Destructive, true); +const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }); +export const ThreadForkTool = Tool.make("t3_thread_fork", { + ...commandTool, + description: + "Fork this thread from a stable run or checkpoint using the existing fork command. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.", + parameters: Schema.Struct({ + sourcePoint: OrchestrationV2ThreadForkSourcePoint, + title: Schema.optional(TrimmedNonEmptyString), + }), + success: transferResult, +}).annotate(Tool.Destructive, true); +export const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { + ...commandTool, + description: + "Merge context from this thread back to a related thread in the same project. Existing lineage and transfer rules apply.", + parameters: Schema.Struct({ + targetThreadId: ThreadId, + sourcePoint: OrchestrationV2ThreadForkSourcePoint, + }), + success: transferResult, +}).annotate(Tool.Destructive, true); +export const ThreadTransfersTool = Tool.make("t3_thread_transfers", { + ...commandTool, + description: "Read context transfer status for a thread in the calling project.", + parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }), + success: Schema.Struct({ + transfers: Schema.Array( + Schema.Struct({ + id: OrchestrationV2ContextTransfer.fields.id, + sourceThreadId: OrchestrationV2ContextTransfer.fields.sourceThreadId, + targetThreadId: OrchestrationV2ContextTransfer.fields.targetThreadId, + status: OrchestrationV2ContextTransfer.fields.status, + }), + ), + }), +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + export const ThreadToolkit = Toolkit.make( + ThreadForkTool, + ThreadMergeBackTool, + ThreadTransfersTool, ThreadConfigurationTool, ThreadConfigureTool, PendingRequestListTool, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6f81b3d9b96d..754da19e1226 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -811,6 +811,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_pending_request_list", "mcp__t3-code__t3_pending_request_read", "mcp__t3-code__t3_thread_configuration", + "mcp__t3-code__t3_thread_transfers", "mcp__t3-code__t3_queue_list", "mcp__t3-code__t3_queue_read", ]; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index c820e292059f..32251e9ba35f 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -59,6 +59,9 @@ const T3_MCP_TOOLS: Record< t3_pending_request_respond: { displayName: "Answer pending questions" }, t3_thread_configuration: { displayName: "Read thread configuration" }, t3_thread_configure: { displayName: "Set thread model" }, + t3_thread_fork: { displayName: "Fork this thread" }, + t3_thread_merge_back: { displayName: "Merge thread context" }, + t3_thread_transfers: { displayName: "Read thread transfers" }, t3_thread_organize: { displayName: "Organize a thread" }, t3_thread_update: { displayName: "Update T3 thread metadata" }, t3_thread_send: { displayName: "Send to a T3 thread", summaryAction: "thread-send" },