diff --git a/GLOSSARY.md b/GLOSSARY.md index 66506f49de..b492b1ee6c 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -3,11 +3,11 @@ Canonical terms for the toolboxmd fork. Names follow the Lightbringer series by Brent Weeks, the source of the company name. -| Term | Meaning | Avoid | -| --- | --- | --- | -| Luxin | The company; also the work agents produce (code, PRs). | | -| Chromeria | This app: the gateway where the user directs every agent across harnesses, with more interfaces (such as the OpenBot mode) over time. Built from the T3 Code fork. | T3 Code Fork, Bridge | -| Drafter | An agent working for the user on one harness and model. | bot (reserved for OpenBot's named Bots) | -| Prism | Planned product name for Model Router: splits work across models and balances capacity. Not renamed yet. | | -| Toolbox | Planned name for the user's own future harness. | | -| T3 Code | The upstream project this fork tracks (`pingdotgg/t3code`). | | +| Term | Meaning | Avoid | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | +| Luxin | The company; also the work agents produce (code, PRs). | | +| Chromeria | This app: the gateway where the user directs every agent across harnesses, with more interfaces (such as the OpenBot mode) over time. Built from the T3 Code fork. | T3 Code Fork, Bridge | +| Drafter | An agent working for the user on one harness and model. | bot (reserved for OpenBot's named Bots) | +| Prism | Planned product name for Model Router: splits work across models and balances capacity. Not renamed yet. | | +| Toolbox | Planned name for the user's own future harness. | | +| T3 Code | The upstream project this fork tracks (`pingdotgg/t3code`). | | diff --git a/apps/server/src/entrypoint.test.ts b/apps/server/src/entrypoint.test.ts index bf8247fd42..0842a628bd 100644 --- a/apps/server/src/entrypoint.test.ts +++ b/apps/server/src/entrypoint.test.ts @@ -48,7 +48,10 @@ describe("isEntrypoint", () => { it.skipIf(!symlinksSupported)( "matches through a symlinked entrypoint, as npm and npx install it", () => { - const dir = makeTempDir(); + // Node reports the entry module URL fully resolved, so resolve the + // fixture dir too: under a symlinked TMPDIR (macOS /var -> /private/var) + // an unresolved dir would test a URL Node never produces. + const dir = NodeFS.realpathSync(makeTempDir()); const real = NodePath.join(dir, "bin.mjs"); const link = NodePath.join(dir, "t3"); NodeFS.writeFileSync(real, ""); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a8cb573ad..ce24afcb62 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -31,6 +31,8 @@ import { } from "./toolkits/preview/tools.ts"; import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import { ThreadsToolkitHandlersLive } from "./toolkits/threads/handlers.ts"; +import { ThreadsToolkit } from "./toolkits/threads/tools.ts"; import { DeviceScreenshotToolkitHandlersLive, DeviceStandardToolkitHandlersLive, @@ -608,6 +610,10 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest Layer.provide(PullRequestsToolkitHandlersLive), ); +export const ThreadsToolkitRegistrationLive = McpServer.toolkit(ThreadsToolkit).pipe( + Layer.provide(ThreadsToolkitHandlersLive), +); + const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe( Layer.provide(DeviceStandardToolkitHandlersLive), ); @@ -631,5 +637,6 @@ const McpTransportLive = McpServer.layerHttp({ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, PullRequestsToolkitRegistrationLive, + ThreadsToolkitRegistrationLive, DeviceToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/toolkits/threads/childThreads.test.ts b/apps/server/src/mcp/toolkits/threads/childThreads.test.ts new file mode 100644 index 0000000000..aca3e7db66 --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/childThreads.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deliveryOf, effortOptionId, subagentStatusOf } from "./handlers.ts"; + +const session = (status: string) => + ({ + status, + providerInstanceId: "codex", + activeTurnId: null, + lastError: null, + }) as unknown as Parameters[0]; + +describe("child thread status vocabulary", () => { + it("maps a missing session to starting so the message guard holds", () => { + expect(subagentStatusOf(null)).toBe("starting"); + }); + + it("collapses provider session states onto the toolkit vocabulary", () => { + expect(subagentStatusOf(session("starting"))).toBe("starting"); + expect(subagentStatusOf(session("running"))).toBe("running"); + expect(subagentStatusOf(session("idle"))).toBe("idle"); + expect(subagentStatusOf(session("ready"))).toBe("idle"); + expect(subagentStatusOf(session("error"))).toBe("failed"); + expect(subagentStatusOf(session("interrupted"))).toBe("stopped"); + expect(subagentStatusOf(session("stopped"))).toBe("stopped"); + }); +}); + +describe("child thread effort routing", () => { + it("uses each driver's advertised reasoning-effort option", () => { + expect(effortOptionId("codex")).toBe("reasoningEffort"); + expect(effortOptionId("grok")).toBe("reasoningEffort"); + expect(effortOptionId("opencode")).toBe("variant"); + }); + + it("falls back to effort for Claude, Cursor and Antigravity", () => { + expect(effortOptionId("claudeAgent")).toBe("effort"); + expect(effortOptionId("cursor")).toBe("effort"); + expect(effortOptionId("antigravity")).toBe("effort"); + }); +}); + +describe("child thread message delivery", () => { + it("steers a running child and opens a turn everywhere else", () => { + expect(deliveryOf("running")).toBe("steer"); + expect(deliveryOf("idle")).toBe("new-turn"); + expect(deliveryOf("failed")).toBe("new-turn"); + expect(deliveryOf("stopped")).toBe("new-turn"); + // starting never reaches deliveryOf: the message tool refuses it first. + expect(deliveryOf("starting")).toBe("new-turn"); + }); +}); diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts new file mode 100644 index 0000000000..7ef9ba5f1e --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -0,0 +1,379 @@ +import { + CommandId, + EventId, + MessageId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationSession, + type OrchestrationThreadShell, + type ProviderOptionSelection, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderService from "../../../provider/Services/ProviderService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { isSubagentThreadId, makeSubagentThreadId, parentThreadIdOf } from "./subagentThreadId.ts"; +import { type SubagentStatus, ThreadsToolError, ThreadsToolkit } from "./tools.ts"; + +const REPORT_TEXT_LIMIT = 4_000; + +/** The toolkit's coarse status vocabulary for a thread's provider session. */ +export function subagentStatusOf(session: OrchestrationSession | null): SubagentStatus { + switch (session?.status) { + case undefined: + case "starting": + return "starting"; + case "running": + return "running"; + case "idle": + case "ready": + return "idle"; + case "error": + return "failed"; + case "interrupted": + case "stopped": + return "stopped"; + } +} + +/** + * Provider option id that carries reasoning effort, per driver. Codex and + * Grok advertise `reasoningEffort`, OpenCode advertises `variant`, and + * Claude, Cursor and Antigravity read `effort`. ModelSelection options are + * free-form id/value pairs that adapters ignore when unknown, so sending + * `effort` to Antigravity (which has no effort control) is a harmless no-op. + */ +export function effortOptionId(driverKind: string): string { + switch (driverKind) { + case "codex": + case "grok": + return "reasoningEffort"; + case "opencode": + return "variant"; + default: + return "effort"; + } +} + +const fail = (reason: string) => Effect.fail(new ThreadsToolError({ reason })); + +/** + * How a message to a child is delivered. A message sent while the child + * works steers its running turn (a new turn supersedes the running one at + * the orchestration layer, uniformly for every provider); an idle, failed + * or stopped child starts a fresh turn. Callers must refuse `starting` + * before reaching here: a message sent before the first turn starts left a + * turn open forever in live runs. + */ +export function deliveryOf(statusBefore: SubagentStatus): "new-turn" | "steer" { + return statusBefore === "running" ? "steer" : "new-turn"; +} + +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const providers = yield* ProviderService.ProviderService; + const crypto = yield* Crypto.Crypto; + + /** Child thread id -> whether its turn results go back to the parent. + * Process-local by design: a restart loses pending reportBack flags and + * the status/message dedupe below, so an already-idle child may re-report + * one turn after a restart. Children created after the restart are + * unaffected. */ + const reportBack = new Map(); + /** Child thread id -> last status the parent's Agents panel was told. */ + const lastStatus = new Map(); + /** Child thread id -> assistant message id last reported to the parent. */ + const lastReported = new Map(); + + const uuid = crypto.randomUUIDv4.pipe(Effect.orDie); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const commandId = (tag: string) => + Effect.map(uuid, (id) => CommandId.make(`server:mcp-threads-${tag}:${id}`)); + + const dispatch = (command: Parameters[0]) => + engine + .dispatch(command) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : fail(`Command ${command.type} failed: ${Cause.pretty(cause).slice(0, 500)}`), + ), + ); + + const threadShell = (threadId: string) => + snapshots.getThreadShellById(ThreadId.make(threadId)).pipe( + Effect.map(Option.getOrUndefined), + Effect.catchCause(() => Effect.succeed(undefined)), + ); + + const lastAssistantMessage = (threadId: string) => + snapshots.getThreadDetailById(ThreadId.make(threadId)).pipe( + Effect.map((thread) => { + if (Option.isNone(thread)) return null; + const messages = thread.value.messages; + const userMessageCount = messages.filter((message) => message.role === "user").length; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]!; + if (message.role === "assistant" && message.text.trim().length > 0) { + return { id: message.id, text: message.text, userMessageCount }; + } + } + return { id: null, text: null, userMessageCount }; + }), + Effect.catchCause(() => Effect.succeed(null)), + ); + + const summarize = (thread: OrchestrationThreadShell) => + Effect.gen(function* () { + const last = yield* lastAssistantMessage(thread.id); + return { + threadId: thread.id, + title: thread.title, + status: subagentStatusOf(thread.session), + instanceId: thread.session?.providerInstanceId ?? thread.modelSelection.instanceId, + model: thread.modelSelection.model, + lastError: thread.session?.lastError ?? null, + lastAssistantMessage: last?.text ?? null, + userMessageCount: last?.userMessageCount ?? 0, + }; + }); + + /** The calling thread, plus a guard that the target is one of its children. */ + const callerChild = (threadId: string) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + if (parentThreadIdOf(threadId) !== scope.threadId) { + return yield* fail(`Thread ${threadId} is not a child of this thread.`); + } + const child = yield* threadShell(threadId); + if (!child) return yield* fail(`Thread ${threadId} was not found.`); + return child; + }); + + const appendParentActivity = ( + parentThreadId: string, + kind: "task.started" | "task.progress" | "task.updated" | "task.completed", + summary: string, + payload: Record, + ) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + yield* dispatch({ + type: "thread.activity.append", + commandId: yield* commandId("activity"), + threadId: ThreadId.make(parentThreadId), + activity: { + id: EventId.make(yield* uuid), + tone: "info", + kind, + summary, + // agentKind "agent" is what admits a task row to the Agents panel. + payload: { agentKind: "agent", taskType: "t3_thread", ...payload }, + turnId: null, + createdAt, + }, + createdAt, + }); + }); + + const startTurn = (thread: OrchestrationThreadShell, text: string) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + yield* dispatch({ + type: "thread.turn.start", + commandId: yield* commandId("turn"), + threadId: thread.id, + message: { + messageId: MessageId.make(yield* uuid), + role: "user", + text, + attachments: [], + }, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt, + }); + }); + + /** + * Mirrors a child thread's lifecycle into its parent's activities as the + * task.* rows the Agents panel already folds, and optionally sends each + * finished turn's reply back to the parent as a message. + */ + const bridge = Effect.fn("ThreadsToolkit.bridge")(function* (event: OrchestrationEvent) { + if (event.aggregateKind !== "thread" || !isSubagentThreadId(event.aggregateId)) return; + const childId = event.aggregateId; + const parentId = parentThreadIdOf(childId)!; + if (event.type === "thread.created") { + const selection = event.payload.modelSelection; + const effort = selection.options?.find((option) => + ["effort", "reasoningEffort", "variant"].includes(option.id), + )?.value; + lastStatus.set(childId, "starting"); + yield* appendParentActivity(parentId, "task.started", `Started ${event.payload.title}`, { + taskId: childId, + title: event.payload.title, + role: selection.instanceId, + model: selection.model, + ...(typeof effort === "string" ? { effort } : {}), + detail: event.payload.title, + }); + return; + } + if (event.type !== "thread.session-set") return; + const status = subagentStatusOf(event.payload.session); + const previous = lastStatus.get(childId); + if (status === previous) return; + lastStatus.set(childId, status); + if (status === "running") { + yield* appendParentActivity(parentId, "task.updated", "Subagent working", { + taskId: childId, + status: "running", + }); + return; + } + if (status === "failed") { + yield* appendParentActivity(parentId, "task.updated", "Subagent failed", { + taskId: childId, + status: "failed", + error: event.payload.session.lastError ?? "Provider session error", + }); + return; + } + if (status === "stopped") { + yield* appendParentActivity(parentId, "task.updated", "Subagent stopped", { + taskId: childId, + status: "interrupted", + }); + return; + } + if (status !== "idle") return; + const last = yield* lastAssistantMessage(childId); + yield* appendParentActivity(parentId, "task.progress", "Subagent idle", { + taskId: childId, + status: "idle", + ...(last?.text ? { summary: last.text } : {}), + }); + if ( + reportBack.get(childId) !== true || + !last?.id || + !last.text || + lastReported.get(childId) === last.id + ) { + return; + } + lastReported.set(childId, last.id); + const parent = yield* threadShell(parentId); + const child = yield* threadShell(childId); + if (!parent) return; + const text = + last.text.length > REPORT_TEXT_LIMIT + ? `${last.text.slice(0, REPORT_TEXT_LIMIT)}…` + : last.text; + yield* startTurn( + parent, + `[Subagent ${child?.title ?? childId} (thread ${childId}) finished a turn]\n\n${text}`, + ); + }); + + // Consume the hot stream like the upstream reactors do. This layer builds + // with the HTTP routes, so it must not acquire an engine subscription at + // build time (`subscribeDomainEvents`). + yield* Effect.forkScoped( + Stream.runForEach(engine.streamDomainEvents, (event) => + bridge(event).pipe( + Effect.catchCause((cause) => + Effect.logWarning("threads toolkit bridge skipped an event", { + eventType: event.type, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); + + return ThreadsToolkit.of({ + spawn_thread: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const parent = yield* threadShell(scope.threadId); + if (!parent) return yield* fail(`Thread ${scope.threadId} was not found.`); + const instanceId = ProviderInstanceId.make( + input.instanceId ?? parent.modelSelection.instanceId, + ); + const sameInstance = instanceId === parent.modelSelection.instanceId; + const model = input.model ?? (sameInstance ? parent.modelSelection.model : undefined); + if (!model) return yield* fail("Pass model when instanceId differs from this thread's."); + const info = yield* providers + .getInstanceInfo(instanceId) + .pipe(Effect.catchCause(() => fail(`Unknown provider instance ${instanceId}.`))); + if (!info.enabled) { + return yield* fail(`Provider instance ${instanceId} is disabled in T3 Code settings.`); + } + const options: ProviderOptionSelection[] = input.effort + ? [{ id: effortOptionId(info.driverKind), value: input.effort }] + : []; + const modelSelection = { + instanceId, + model, + ...(options.length > 0 ? { options } : {}), + }; + const childId = ThreadId.make( + makeSubagentThreadId(parent.id, (yield* uuid).replaceAll("-", "").slice(0, 12)), + ); + reportBack.set(childId, input.reportBack !== false); + const createdAt = yield* nowIso; + const runtimeMode = input.runtimeMode ?? parent.runtimeMode; + yield* dispatch({ + type: "thread.create", + commandId: yield* commandId("create"), + threadId: childId, + projectId: parent.projectId, + title: input.title ?? `Subagent: ${input.task.slice(0, 60)}`, + modelSelection, + runtimeMode, + interactionMode: "default", + branch: parent.branch, + worktreePath: parent.worktreePath, + createdAt, + }); + const child = yield* threadShell(childId); + if (!child) return yield* fail(`Child thread ${childId} was not created.`); + yield* startTurn(child, input.task); + return { threadId: childId, parentThreadId: parent.id, instanceId, model }; + }), + message_thread: ({ threadId, text }) => + Effect.gen(function* () { + const child = yield* callerChild(threadId); + const statusBefore = subagentStatusOf(child.session); + if (statusBefore === "starting") { + return yield* fail(`Thread ${threadId} is still starting. Retry in a few seconds.`); + } + yield* startTurn(child, text); + return { threadId, statusBefore, delivery: deliveryOf(statusBefore) }; + }), + read_thread: ({ threadId }) => callerChild(threadId).pipe(Effect.flatMap(summarize)), + list_child_threads: () => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const shells = yield* snapshots.getShellSnapshot().pipe( + Effect.map((snapshot) => snapshot.threads), + Effect.catchCause(() => fail("Could not read threads.")), + ); + const children = shells.filter((thread) => parentThreadIdOf(thread.id) === scope.threadId); + return { threads: yield* Effect.forEach(children, summarize) }; + }), + }); +}); + +export const ThreadsToolkitHandlersLive = ThreadsToolkit.toLayer(make); diff --git a/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts b/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts new file mode 100644 index 0000000000..ac578f7d7f --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/subagentThreadId.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isSubagentThreadId, makeSubagentThreadId, parentThreadIdOf } from "./subagentThreadId.ts"; + +describe("subagent thread ids", () => { + it("round-trips the parent, including nested children", () => { + const child = makeSubagentThreadId("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f", "abc123"); + expect(isSubagentThreadId(child)).toBe(true); + expect(parentThreadIdOf(child)).toBe("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f"); + const grandchild = makeSubagentThreadId(child, "def456"); + expect(parentThreadIdOf(grandchild)).toBe(child); + }); + + it("leaves user threads alone", () => { + expect(isSubagentThreadId("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f")).toBe(false); + expect(parentThreadIdOf("sub.")).toBeNull(); + }); +}); diff --git a/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts b/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts new file mode 100644 index 0000000000..761ed2e4d0 --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/subagentThreadId.ts @@ -0,0 +1,31 @@ +/** + * Child-thread identity (toolboxmd/t3code#8): a thread spawned by another + * thread through the `threads` MCP toolkit carries its parent in its own id, + * `sub..`. + * + * A real optional `parentThreadId` on `thread.create` carried to the thread + * shell would be cleaner, but it is too invasive for this fork: it would + * require coordinated changes to ThreadCreateCommand, ThreadCreatedPayload, + * OrchestrationThread, OrchestrationThreadShell, the decider, the projector, + * the ProjectionThread persistence schema plus SQLite layer plus a new + * migration, and the shell snapshot mapping, with backfill for existing + * threads. The id convention needs none of that, survives restarts, and lets + * every client classify a thread from its shell alone. + * + * Keep in sync with apps/web/src/components/subagentThreads.ts. + */ +const PREFIX = "sub."; + +export function makeSubagentThreadId(parentThreadId: string, suffix: string): string { + return `${PREFIX}${parentThreadId}.${suffix}`; +} + +export function isSubagentThreadId(threadId: string): boolean { + return threadId.startsWith(PREFIX) && threadId.lastIndexOf(".") > PREFIX.length; +} + +/** The spawning thread's id, or null for a thread the user started. */ +export function parentThreadIdOf(threadId: string): string | null { + if (!isSubagentThreadId(threadId)) return null; + return threadId.slice(PREFIX.length, threadId.lastIndexOf(".")); +} diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts new file mode 100644 index 0000000000..d62e27b835 --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -0,0 +1,143 @@ +import { RuntimeMode, TrimmedNonEmptyString } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/unstable/ai/Tool"; +import * as Toolkit from "effect/unstable/ai/Toolkit"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext]; + +export class ThreadsToolError extends Schema.TaggedError()("ThreadsToolError", { + reason: Schema.String, +}) { + override get message(): string { + return this.reason; + } +} + +export const SubagentStatus = Schema.Literals(["starting", "running", "idle", "failed", "stopped"]); +export type SubagentStatus = typeof SubagentStatus.Type; + +export const SpawnThreadInput = Schema.Struct({ + task: TrimmedNonEmptyString.annotate({ + description: "The first message the child thread receives: its whole task.", + }), + instanceId: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Provider instance to run the child on, for example claudeAgent, codex, opencode or grok. Defaults to this thread's provider instance.", + }), + ), + model: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Model id on that instance. Defaults to this thread's model.", + }), + ), + effort: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Reasoning effort (Claude effort, Codex/Grok reasoningEffort, OpenCode variant).", + }), + ), + title: Schema.optional(TrimmedNonEmptyString), + runtimeMode: Schema.optional(RuntimeMode), + reportBack: Schema.optional( + Schema.Boolean.annotate({ + description: + "When true (default), each time the child finishes a turn its final reply is sent to this thread as a message.", + }), + ), +}); + +export const SpawnThreadResult = Schema.Struct({ + threadId: Schema.String, + parentThreadId: Schema.String, + instanceId: Schema.String, + model: Schema.String, +}); + +export const MessageThreadInput = Schema.Struct({ + threadId: TrimmedNonEmptyString.annotate({ description: "A child thread of this thread." }), + text: TrimmedNonEmptyString, +}); + +export const MessageThreadResult = Schema.Struct({ + threadId: Schema.String, + statusBefore: SubagentStatus, + delivery: Schema.Literals(["new-turn", "steer"]).annotate({ + description: + "new-turn: the child was idle and starts a turn. steer: the message joins the child's running turn.", + }), +}); + +export const ThreadSummary = Schema.Struct({ + threadId: Schema.String, + title: Schema.String, + status: SubagentStatus, + instanceId: Schema.NullOr(Schema.String), + model: Schema.String, + lastError: Schema.NullOr(Schema.String), + lastAssistantMessage: Schema.NullOr(Schema.String), + userMessageCount: Schema.Int, +}); + +const SpawnThreadTool = Tool.make("spawn_thread", { + description: + "Start a child thread in this project on a chosen provider instance, model and effort, and send it a task. Returns immediately with the child's thread id; the child shows in this thread's Agents panel. Use read_thread to see its reply, message_thread to talk to it.", + parameters: SpawnThreadInput, + success: SpawnThreadResult, + failure: ThreadsToolError, + dependencies, +}) + .annotate(Tool.Title, "Spawn child thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + +const MessageThreadTool = Tool.make("message_thread", { + description: + "Send a message to one of this thread's child threads once it has started working or gone idle. A child that is still starting refuses with a retryable error; retry in a few seconds.", + parameters: MessageThreadInput, + success: MessageThreadResult, + failure: ThreadsToolError, + dependencies, +}) + .annotate(Tool.Title, "Message child thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + +const ReadThreadTool = Tool.make("read_thread", { + description: + "Read a child thread's status and its latest assistant reply. Poll it, or rely on reportBack, to learn when the child is done.", + parameters: Schema.Struct({ threadId: TrimmedNonEmptyString }), + success: ThreadSummary, + failure: ThreadsToolError, + dependencies, +}) + .annotate(Tool.Title, "Read child thread") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const ListChildThreadsTool = Tool.make("list_child_threads", { + description: "List this thread's child threads and their status.", + success: Schema.Struct({ threads: Schema.Array(ThreadSummary) }), + failure: ThreadsToolError, + dependencies, +}) + .annotate(Tool.Title, "List child threads") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const ThreadsToolkit = Toolkit.make( + SpawnThreadTool, + MessageThreadTool, + ReadThreadTool, + ListChildThreadsTool, +); diff --git a/apps/web/src/components/AgentThreadLink.tsx b/apps/web/src/components/AgentThreadLink.tsx new file mode 100644 index 0000000000..810327e565 --- /dev/null +++ b/apps/web/src/components/AgentThreadLink.tsx @@ -0,0 +1,30 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +import { isSubagentThreadId } from "./subagentThreads"; + +/** + * Agents panel rows for child threads (toolboxmd/t3code#8) open that + * thread, where the user reads it and messages it with the normal composer. + * Rows for native provider subagents stay plain. + */ +export function AgentThreadLink(props: { + agentId: string; + environmentId: EnvironmentId | null; + children: ReactNode; +}) { + if (props.environmentId === null || !isSubagentThreadId(props.agentId)) { + return props.children; + } + return ( + + {props.children} + + ); +} diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 43f1fa123f..8dd3f0956f 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -25,6 +25,7 @@ import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; +import { AgentThreadLink } from "~/components/AgentThreadLink"; import { orchestrationEnvironment } from "~/state/orchestration"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Button } from "~/components/ui/button"; @@ -561,7 +562,9 @@ export function AgentsPanel({ Direct spawns {model.directAgents.map((agent) => ( - + + + ))} ) : null} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 075ccea0a1..8d5d9a4a29 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -23,6 +23,7 @@ import { } from "./ThreadStatusIndicators"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; +import { isSubagentThreadId } from "./subagentThreads"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -1331,7 +1332,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }); }; const visibleProjectThreads = sortThreads( - projectThreads.filter((thread) => thread.archivedAt === null), + // Child threads open from their parent's Agents panel, not the sidebar. + projectThreads.filter( + (thread) => thread.archivedAt === null && !isSubagentThreadId(thread.id), + ), threadSortOrder, ); const projectStatus = resolveProjectStatusIndicator( @@ -3398,7 +3402,11 @@ export default function LegacySidebar() { }, []); const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), + // Child threads open from their parent's Agents panel, not the sidebar. + () => + sidebarThreads.filter( + (thread) => thread.archivedAt === null && !isSubagentThreadId(thread.id), + ), [sidebarThreads], ); const sortedProjects = useMemo(() => { @@ -3438,7 +3446,8 @@ export default function LegacySidebar() { sortedProjects.flatMap((project) => { const projectThreads = sortThreads( (threadsByProjectKey.get(project.projectKey) ?? []).filter( - (thread) => thread.archivedAt === null, + // Child threads open from their parent's Agents panel, not the sidebar. + (thread) => thread.archivedAt === null && !isSubagentThreadId(thread.id), ), sidebarThreadSortOrder, ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 12b06d7394..13cdbb80af 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -211,6 +211,7 @@ import { import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Sidebar.snooze"; import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; import { ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; +import { isSubagentThreadId } from "./subagentThreads"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -2532,6 +2533,8 @@ export default function Sidebar() { const visible = threads.filter( (thread) => thread.archivedAt === null && + // Child threads open from their parent's Agents panel, not the sidebar. + !isSubagentThreadId(thread.id) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); diff --git a/apps/web/src/components/subagentThreads.test.ts b/apps/web/src/components/subagentThreads.test.ts new file mode 100644 index 0000000000..9f08a26816 --- /dev/null +++ b/apps/web/src/components/subagentThreads.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isSubagentThreadId, makeSubagentThreadId, parentThreadIdOf } from "./subagentThreads"; + +describe("child thread ids", () => { + it("round-trips the parent, including nested children", () => { + const child = makeSubagentThreadId("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f", "abc123"); + expect(isSubagentThreadId(child)).toBe(true); + expect(parentThreadIdOf(child)).toBe("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f"); + const grandchild = makeSubagentThreadId(child, "def456"); + expect(parentThreadIdOf(grandchild)).toBe(child); + }); + + it("leaves user threads alone so the sidebar keeps showing them", () => { + expect(isSubagentThreadId("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f")).toBe(false); + expect(parentThreadIdOf("0b6f7c1e-2d3a-4c5b-9e8f-1a2b3c4d5e6f")).toBeNull(); + expect(parentThreadIdOf("sub.")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/subagentThreads.ts b/apps/web/src/components/subagentThreads.ts new file mode 100644 index 0000000000..880653b73d --- /dev/null +++ b/apps/web/src/components/subagentThreads.ts @@ -0,0 +1,26 @@ +/** + * Child-thread identity (toolboxmd/t3code#8): a thread spawned by another + * thread through the `threads` MCP toolkit carries its parent in its own id, + * `sub..`. It needs no contract, projector or + * migration change, survives restarts, and lets every client classify a + * thread from its shell alone. A real optional `parentThreadId` on + * `thread.create` would be cleaner but is too invasive for this fork (see + * the server copy for the full tradeoff). + * + * Keep in sync with apps/server/src/mcp/toolkits/threads/subagentThreadId.ts. + */ +const PREFIX = "sub."; + +export function makeSubagentThreadId(parentThreadId: string, suffix: string): string { + return `${PREFIX}${parentThreadId}.${suffix}`; +} + +export function isSubagentThreadId(threadId: string): boolean { + return threadId.startsWith(PREFIX) && threadId.lastIndexOf(".") > PREFIX.length; +} + +/** The spawning thread's id, or null for a thread the user started. */ +export function parentThreadIdOf(threadId: string): string | null { + if (!isSubagentThreadId(threadId)) return null; + return threadId.slice(PREFIX.length, threadId.lastIndexOf(".")); +} diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 3645eeb5c1..481fe8716d 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2077,6 +2077,12 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node // and the bun adapters are covered by the shared runtime-external closure // and emitted-bundle checks. + // The probe must run under plain Node even when the orchestrator itself + // runs as ELECTRON_RUN_AS_NODE (e.g. tests spawned from an Electron + // toolchain shell): inheriting that flag would validate the bundle under + // a different runtime than users get. + const probeEnv: Record = { ...process.env, NODE_PATH: "" }; + delete probeEnv.ELECTRON_RUN_AS_NODE; yield* runCommand( ChildProcess.make( process.execPath, @@ -2092,7 +2098,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // NODE_PATH would let a createRequire call inside the bundle resolve // a missing external from outside the packaged tree, which is the // whole thing this is trying to rule out. - env: { ...process.env, NODE_PATH: "" }, + env: probeEnv, }, ), {