diff --git a/.changeset/lazy-global-search-startup.md b/.changeset/lazy-global-search-startup.md new file mode 100644 index 0000000000..bb5fd4f520 --- /dev/null +++ b/.changeset/lazy-global-search-startup.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix several seconds of startup lag: the global search index (used only by the web UI's search) was being opened and synced in every terminal session, including ones that never search. It now loads on demand, so interactive startup stays fast. diff --git a/.changeset/queue-skill-commands-while-busy.md b/.changeset/queue-skill-commands-while-busy.md new file mode 100644 index 0000000000..c79c7f0067 --- /dev/null +++ b/.changeset/queue-skill-commands-while-busy.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Queue slash skill commands entered while the agent is busy instead of rejecting them with "Cannot / while streaming" — they now behave exactly like normal input: queued visibly by default, and Ctrl-S steers them into the running turn as real skill activations. diff --git a/.changeset/tower-slash-command.md b/.changeset/tower-slash-command.md new file mode 100644 index 0000000000..c65709853d --- /dev/null +++ b/.changeset/tower-slash-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /tower slash command to orchestrate multiple agents iterating on one repo in parallel — you act as the control tower while worker agents execute missions in their own git worktrees. Run /tower to start. diff --git a/GOAL.md b/GOAL.md index c0fdc2a36f..2ab2712258 100644 --- a/GOAL.md +++ b/GOAL.md @@ -229,3 +229,4 @@ goal ID 不应暴露给模型,因为它只是 runtime/UI 内部标识,没有 goal 创建、暂停、恢复、阻塞、完成、清除都应发出 goal updated 事件。lifecycle 变化和 completion 变化应区分。completion 是一次终局事件,然后 snapshot 变 null。blocked/paused 保留 snapshot,UI 可以继续展示可恢复 goal。 session 恢复时,active goal 会变 paused,避免重启后自动继续。fork session 时不继承 goal,并提醒模型不要继续源 session 的目标。 + diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts index e67457a94b..9df6dbfea0 100644 --- a/apps/kimi-code/src/tui/commands/resolve.ts +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -83,14 +83,10 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name); if (skillName !== undefined) { - const busyReason = slashCommandBusyReason(options); - if (busyReason !== undefined) { - return { - kind: 'blocked', - commandName: parsed.name, - reason: busyReason, - }; - } + // Skill activations are never blocked by a busy session: the TUI queues + // them behind the running turn exactly like normal messages (see + // sendSkillActivation), and Ctrl-S steers them as real activations, so + // commands like /tower can be issued any time. return { kind: 'skill', commandName: parsed.name, diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index 1a2b26d078..209c902666 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -23,7 +23,7 @@ export class QueuePaneComponent extends Container { if (options.messages.length > 0) { // Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when - // there is at least one plain-text item that steering would actually send. + // there is at least one plain-text or skill item steering would send. const hasSteerable = options.messages.some((m) => m.mode !== 'bash'); const canSteer = options.canSteerImmediately && hasSteerable; this.hint = diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index dc86d312a5..90abc4b1b5 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -35,6 +35,7 @@ export interface EditorKeyboardHost { handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; steerMessage(session: Session, input: readonly SteerInputItem[]): void; + steerSkillActivation(session: Session, skillName: string, skillArgs: string): void; validateMediaCapabilities(extraction: { hasMedia: boolean; imageAttachmentIds: readonly number[]; @@ -270,18 +271,35 @@ export class EditorKeyboardController { const text = editor.getText().trim(); const editorIsBash = editor.inputMode === 'bash'; - // Bash commands (`! …`) are not steerable: keep them queued so they run - // after the current task instead of being injected into the turn as text. + // Bash commands (`! …`) are not steerable: they stay queued so they run + // after the current task. Everything else steers in queue order — + // plain text as a steered message, slash-skill items as activations + // fired into the running turn (never as literal text). const queued = host.state.queuedMessages; const steerable = queued.filter((m) => m.mode !== 'bash'); - const items: SteerInputItem[] = []; + type SteerRun = + | { readonly kind: 'text'; readonly items: SteerInputItem[] } + | { readonly kind: 'skill'; readonly skillName: string; readonly skillArgs: string }; + const runs: SteerRun[] = []; + let textRun: SteerInputItem[] = []; + const flushTextRun = (): void => { + if (textRun.length > 0) { + runs.push({ kind: 'text', items: textRun }); + textRun = []; + } + }; for (const m of steerable) { + if (m.mode === 'skill' && m.skillName !== undefined) { + flushTextRun(); + runs.push({ kind: 'skill', skillName: m.skillName, skillArgs: m.skillArgs ?? '' }); + continue; + } const trimmed = m.text.trim(); if (trimmed.length > 0) { // Queued items carry the parts extracted when they were submitted // (and were already capability-validated then). - items.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds }); + textRun.push({ text: trimmed, parts: m.parts, imageAttachmentIds: m.imageAttachmentIds }); } } let editorExtraction: ReturnType | undefined; @@ -294,7 +312,7 @@ export class EditorKeyboardController { host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); return; } - items.push({ + textRun.push({ text, parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined, imageAttachmentIds: @@ -303,8 +321,9 @@ export class EditorKeyboardController { : undefined, }); } + flushTextRun(); - if (items.length > 0) { + if (runs.length > 0) { // The editor draft is fresh input: gate it on the model's media // capabilities before splicing the queue, so a rejection leaves the // queue and the draft untouched. @@ -320,7 +339,13 @@ export class EditorKeyboardController { if (host.state.appState.model.trim().length === 0 || session === undefined) { host.showError(LLM_NOT_SET_MESSAGE); } else { - host.steerMessage(session, items); + for (const run of runs) { + if (run.kind === 'text') { + host.steerMessage(session, run.items); + } else { + host.steerSkillActivation(session, run.skillName, run.skillArgs); + } + } } } host.updateQueueDisplay(); @@ -354,7 +379,9 @@ export class EditorKeyboardController { editor.setText(recalled.text); // Restore the queued item's mode so a recalled `!` command runs as a // shell command again instead of being submitted as a normal prompt. - const mode = recalled.mode ?? 'prompt'; + // Skill activations recall as prompt mode: their text is the original + // `/name args` slash command, which re-parses on submit. + const mode = recalled.mode === 'bash' ? 'bash' : 'prompt'; if (editor.inputMode !== mode) { editor.inputMode = mode; editor.onInputModeChange?.(mode); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 614b694668..dfd397432e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1481,6 +1481,12 @@ export class KimiTUI { void this.runShellCommandFromInput(item.text); return; } + if (item.mode === 'skill' && item.skillName !== undefined) { + // sendSkillActivation re-checks the busy state, so a premature drain + // re-queues at the tail instead of racing the running turn. + this.sendSkillActivation(session, item.skillName, item.skillArgs ?? ''); + return; + } this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { this.sendMessageInternal(session, item.text, { parts: item.parts, @@ -1547,6 +1553,29 @@ export class KimiTUI { return; } if (!this.validateMediaCapabilities(rewrite)) return; + // Compacting (or deferred input): queue behind it — visible and recallable. + // Slash-skill items steer like any queued input on Ctrl-S (the activation + // fires into the running turn instead of the literal text) — see + // editor-keyboard.ts. + // A running turn queues the activation too: every skill behaves like + // plain input — queued by default, steered on demand — because the engine + // steers activations into a running turn exactly like a steered user + // message (v2 `prompt.inject`, v1 `SkillManager.recordActivation`). + const turnRunning = this.state.appState.streamingPhase !== 'idle'; + if (this.deferUserMessages || this.state.appState.isCompacting || turnRunning) { + const args = rewrite.text.trim(); + this.state.queuedMessages.push({ + text: `/${skillName}${args.length > 0 ? ` ${args}` : ''}`, + agentId: this.harness.interactiveAgentId, + mode: 'skill', + skillName, + skillArgs: rewrite.text, + }); + this.track('input_queue'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } this.beginSessionRequest(); void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => { const message = formatErrorMessage(error); @@ -1626,6 +1655,15 @@ export class KimiTUI { }); } + steerSkillActivation(session: Session, skillName: string, skillArgs: string): void { + // Ctrl-S on a queued slash-skill item: the activation fires into the + // running turn (the engine steers it there, never the literal text). No + // beginSessionRequest — the live pane belongs to the running turn. + void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + this.showError(`Skill "${skillName}" failed: ${formatErrorMessage(error)}`); + }); + } + // ========================================================================= // State & Accessors // ========================================================================= diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 275ac35d48..62fdd7b990 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -258,8 +258,14 @@ export interface QueuedMessage { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; /** `bash` for a `!` shell command queued while another command is running; + * `skill` for a slash-skill activation queued while the session is busy; * undefined (=`prompt`) for a normal message. */ - readonly mode?: 'prompt' | 'bash'; + readonly mode?: 'prompt' | 'bash' | 'skill'; + /** Set when mode === 'skill': the skill to activate when the item drains. + * `text` then holds the display/recall string (`/name args`). */ + readonly skillName?: string; + /** Set when mode === 'skill': the raw (media-rewritten) args to activate with. */ + readonly skillArgs?: string; } /** diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 614553bb42..77475578ce 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -195,7 +195,7 @@ describe('resolveSlashCommandInput', () => { }); }); - it('resolves skill commands and blocks them while busy', () => { + it('resolves skill commands and keeps them resolvable while busy (queued downstream)', () => { const skillCommandMap = new Map([['skill:review', 'review']]); expect(resolve('/skill:review src/app.ts', { skillCommandMap })).toEqual({ @@ -205,13 +205,14 @@ describe('resolveSlashCommandInput', () => { args: 'src/app.ts', }); expect(resolve('/skill:review src/app.ts', { skillCommandMap, isStreaming: true })).toEqual({ - kind: 'blocked', + kind: 'skill', commandName: 'skill:review', - reason: 'streaming', + skillName: 'review', + args: 'src/app.ts', }); }); - it('resolves unprefixed built-in skill commands and blocks them while busy', () => { + it('resolves unprefixed built-in skill commands and keeps them resolvable while busy', () => { const skillCommandMap = new Map([['mcp-config', 'mcp-config']]); expect(resolve('/mcp-config', { skillCommandMap })).toEqual({ @@ -221,9 +222,10 @@ describe('resolveSlashCommandInput', () => { args: '', }); expect(resolve('/mcp-config', { skillCommandMap, isCompacting: true })).toEqual({ - kind: 'blocked', + kind: 'skill', commandName: 'mcp-config', - reason: 'compacting', + skillName: 'mcp-config', + args: '', }); }); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index 049d2e4801..a84218de42 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -366,3 +366,65 @@ describe('EditorKeyboardController Shift-Tab plan toggle', () => { expect(handlePlanToggle).not.toHaveBeenCalled(); }); }); + + +/** + * Ctrl-S steering of the TUI queue: plain-text items steer as messages, + * slash-skill items fire as real activations into the running turn (never as + * literal text), bash items stay queued — all in queue order. + */ +describe('EditorKeyboardController Ctrl-S steering', () => { + it('steers text as a message, skill items as activations, and keeps bash queued', () => { + const editor: Record unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + setInputMode: vi.fn() as unknown as (...args: never[]) => unknown, + getText: vi.fn(() => '') as unknown as (...args: never[]) => unknown, + setText: vi.fn() as unknown as (...args: never[]) => unknown, + inputMode: 'prompt' as unknown as (...args: never[]) => unknown, + }; + const steerMessage = vi.fn(); + const steerSkillActivation = vi.fn(); + const updateQueueDisplay = vi.fn(); + const session = { id: 'ses-1' }; + const host = { + state: { + editor, + queuedMessages: [ + { text: 'queued text', agentId: 'main' }, + { text: '/tower status', agentId: 'main', mode: 'skill', skillName: 'tower', skillArgs: 'status' }, + { text: '!ls', agentId: 'main', mode: 'bash' }, + ], + appState: { + streamingPhase: 'waiting', + isCompacting: false, + model: 'mock-model', + }, + ui: { requestRender: vi.fn() }, + }, + session, + steerMessage, + steerSkillActivation, + updateQueueDisplay, + validateMediaCapabilities: vi.fn(() => true), + showError: vi.fn(), + track: vi.fn(), + } as unknown as EditorKeyboardHost; + + const controller = new EditorKeyboardController( + host, + undefined as unknown as ImageAttachmentStore, + ); + controller.install(); + + const handler = editor['onCtrlS']; + expect(handler).toBeDefined(); + (handler as () => void)(); + + expect(steerMessage).toHaveBeenCalledWith(session, [ + { text: 'queued text', parts: undefined, imageAttachmentIds: undefined }, + ]); + expect(steerSkillActivation).toHaveBeenCalledWith(session, 'tower', 'status'); + expect(host.state.queuedMessages).toEqual([{ text: '!ls', agentId: 'main', mode: 'bash' }]); + expect(updateQueueDisplay).toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 619ecf2c2f..5c9499fd99 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -2584,6 +2584,90 @@ command = "vim" expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); }); + it('queues a slash-skill activation while a turn is streaming (like any other input) and activates on drain', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { + name: 'tower', + description: 'multi-agent tower mode', + path: 'builtin://tower', + source: 'builtin', + type: 'inline', + }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.streamingPhase = 'waiting'; + harness.track.mockClear(); + + driver.handleUserInput('/tower refactor auth and ui'); + + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { + text: '/tower refactor auth and ui', + agentId: 'main', + mode: 'skill', + skillName: 'tower', + skillArgs: 'refactor auth and ui', + }, + ]); + expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); + + // Turn ends: the drain re-enters sendSkillActivation, which now fires. + driver.state.appState.streamingPhase = 'idle'; + const queued = driver.state.queuedMessages[0]!; + driver.state.queuedMessages = []; + driver.sendQueuedMessage(session, queued); + + expect(session.activateSkill).toHaveBeenCalledWith('tower', 'refactor auth and ui'); + }); + + it('queues a slash-skill activation while compacting and activates it on drain', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { + name: 'tower', + description: 'multi-agent tower mode', + path: 'builtin://tower', + source: 'builtin', + type: 'inline', + }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.isCompacting = true; + harness.track.mockClear(); + + driver.handleUserInput('/tower refactor auth and ui'); + + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { + text: '/tower refactor auth and ui', + agentId: 'main', + mode: 'skill', + skillName: 'tower', + skillArgs: 'refactor auth and ui', + }, + ]); + expect(driver.state.queueContainer.children.length).toBeGreaterThan(0); + expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); + + driver.state.appState.isCompacting = false; + const queued = driver.state.queuedMessages[0]!; + driver.state.queuedMessages = []; + driver.sendQueuedMessage(session, queued); + + expect(session.activateSkill).toHaveBeenCalledWith('tower', 'refactor auth and ui'); + }); + it('steers fresh input while a goal is active even when the streaming phase is idle', async () => { const { driver, session } = await makeDriver(); driver.state.appState.goal = makeActiveGoalSnapshot(); diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index 6d7ef505e8..13df180482 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -3,9 +3,7 @@ // Do NOT add local interfaces that duplicate upstream shapes. export type { - AgentRecord, AgentRecordEvents, - AgentRecordOf, AgentConfigUpdateData, CompactionBeginData, CompactionResult, @@ -30,7 +28,28 @@ export type { Message, ContentPart, ToolCall, TokenUsage } from '@moonshot-ai/ko // Local bindings for the upstream types referenced by the vis-only DTOs // below. The `export type { … }` re-export above forwards the names to // consumers but does NOT bring them into this module's scope. -import type { AgentRecord, BackgroundTaskInfo } from '@moonshot-ai/agent-core'; +import type { + AgentRecord as UpstreamAgentRecord, + BackgroundTaskInfo, +} from '@moonshot-ai/agent-core'; + +/** + * The wire record union vis projects, widened with the v2-engine tower-mode + * records (`tower_mode.enter` / `tower_mode.exit`, empty payloads). The + * upstream v1 union is frozen ahead of its deprecation and does not carry + * them; the local widening keeps the context projector's exhaustiveness + * check covering tower session wires. + */ +export type AgentRecord = + | UpstreamAgentRecord + | { readonly type: 'tower_mode.enter'; readonly time?: number } + | { readonly type: 'tower_mode.exit'; readonly time?: number }; + +/** Extract one record kind from the (locally widened) union. */ +export type AgentRecordOf = Extract< + AgentRecord, + { readonly type: K } +>; /** * Persistent representation of a cron task. diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index b70e988153..838ade9634 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -498,6 +498,9 @@ export function projectContext( case 'swarm_mode.exit': swarm = { active: false }; break; + case 'tower_mode.enter': + case 'tower_mode.exit': + break; // Kinds that don't affect the projected timeline / derived state, // including the observability records (request trace — `llm.*`, // `mcp.tools_discovered`), which are never part of context state: diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index d59b239cf0..b4156433ee 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -590,6 +590,18 @@ export const WIRE_RENDERERS: RendererMap = { headline: () => ({ main: swarm mode exited }), }, + 'tower_mode.enter': { + tone: 'subagent', + label: 'tower↻', + headline: () => ({ main: tower mode entered }), + }, + + 'tower_mode.exit': { + tone: 'subagent', + label: 'tower✓', + headline: () => ({ main: tower mode exited }), + }, + 'goal.create': { tone: 'lifecycle', label: 'goal+', diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 8d370cde78..b7475bdf92 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -19,7 +19,7 @@ The DI kernel (`src/_base/di/`) owns the unit layer on top of the scoped registr The four contribution seams (token → fold): config sections — `ConfigSectionContribution` → `ConfigRegistry` fold (`src/app/config/`; module-level `registerConfigSection` stays the static built-in channel drained at construction, a withdrawn runtime record unregisters the section while user TOML values survive); agent tools — `AgentToolContribution` → `AgentToolActivationService` fold (built-in records provided once at App scope by `builtinToolAssemblyService`; `registerAgentToolService` stays the static channel: Agent-scope DI `OnDemand` registration + module table); agent profiles — `AgentProfileContribution` → `IAgentProfileRegistry` fold (see Scopes); wire vocabulary — `WireModelContribution` → `WireService` fold (a record bundles `models` / `ops` / `crossReducers` / `checkpointedModels`; the built-in layer is the module tables drained at fold time — `defineOp` / `defineModel` / `defineCheckpointedModel` stay the static channel — and replaying a withdrawn domain's history lands on the unknown-op skip-and-count path). A fifth seam: executable commands — `CommandContribution` → `IAgentCommandService` fold (`src/agent/command/`; a contributed command runs engine-side — `run(ctx)` gets `ctx.get` resolving through the agent container, valid only during the synchronous part of `run`; name-level dedup, last record wins; surfaced over RPC as `agentCommandService.list` / `run`). -`src/features/` — built-in capabilities authored as self-contained Feature units (`plan` was the first, extracted from `agent/plan` + `agent/tools/plan`; `swarm` followed, extracted from `agent/swarm` + `session/swarm` + `agent/tools/agent-swarm` into a scope-organized `agent/` + `session/` + `tools/` layout). A `Feature` (`src/features/feature.ts`) is an App-scope unit recipe with a `static override readonly name` and `contribute*` helpers composing the seams: `contributeService(scope, id, ctor)` / `contributeAgentService` (per-scope materialization via `ScopeUnits` — provider death retracts everywhere, 连坐), `contributeTool` (per-agent `OnDemand` registration + the `AgentToolContribution` record), `contributeProfiles`, `contributeConfig`, `contributeCommand`, plus `onDispose`. Feature modules self-register at import (`registerFeature`, `src/features/featureRegistry.ts`); the App-scope `IFeatureAssemblyService` drains the table through `IFeatureManager.provideUnit`, so every feature is a named, introspectable, retractable managed unit. Built-in features keep user-facing static contracts — config sections, agent profiles, wire vocabulary — on the static import=register channels (the config/state manifest generators read static tables / call sites; wire records must stay replayable); the Feature unit carries the runtime capabilities (services, tools, commands). The string form of the unit `on(...)` capability (`this.on('turn.ended', …)`) is backed by the production `FiberEventResolver` registered in `src/app/event/fiberEventResolver.ts`, resolving against the scope's `IEventBus`. +`src/features/` — built-in capabilities authored as self-contained Feature units (`plan` was the first, extracted from `agent/plan` + `agent/tools/plan`; `swarm` followed, extracted from `agent/swarm` + `session/swarm` + `agent/tools/agent-swarm` into a scope-organized `agent/` + `session/` + `tools/` layout; `tower` lives here as `features/tower/` — protocol store, rate limit, tower-mode service, eleven `Tower*` tools, the `tower-worker` profile, and the `/tower` skill body). A `Feature` (`src/features/feature.ts`) is an App-scope unit recipe with a `static override readonly name` and `contribute*` helpers composing the seams: `contributeService(scope, id, ctor)` / `contributeAgentService` (per-scope materialization via `ScopeUnits` — provider death retracts everywhere, 连坐), `contributeTool` (per-agent `OnDemand` registration + the `AgentToolContribution` record), `contributeProfiles`, `contributeConfig`, `contributeCommand`, plus `onDispose`. Feature modules self-register at import (`registerFeature`, `src/features/featureRegistry.ts`); the App-scope `IFeatureAssemblyService` drains the table through `IFeatureManager.provideUnit`, so every feature is a named, introspectable, retractable managed unit. Built-in features keep user-facing static contracts — config sections, agent profiles, wire vocabulary — on the static import=register channels (the config/state manifest generators read static tables / call sites; wire records must stay replayable); the Feature unit carries the runtime capabilities (services, tools, commands). The string form of the unit `on(...)` capability (`this.on('turn.ended', …)`) is backed by the production `FiberEventResolver` registered in `src/app/event/fiberEventResolver.ts`, resolving against the scope's `IEventBus`. ## Ledger and cascade (L0/L2) @@ -78,6 +78,8 @@ Business domains **do not implement persistence themselves** — they depend on Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / atomic writes, or hold file handles. Generic Stores are named by **access pattern** (`IAppendLogStore`, `IAtomicDocumentStore`); only domain-unique Stores are named after the domain (`ISessionIndex`). See `.agents/skills/agent-core-dev/persistence.md` for the full layering rules and decision tree. +One accepted exception: `features/tower/protocol` manages the `.tower/` directory inside the *user's* repository (worktree slots, comms files, activity log) — workspace content, not engine state — and is a verbatim port of the v1 protocol whose semantics (atomic tmp+rename, real `git` CLI for worktrees/merges) are the feature. It keeps direct `node:fs` / `node:child_process` access; do not "modernize" it onto the Stores above without a dedicated migration. + ## Session index `ISessionIndex` (`src/app/sessionIndex/`, App scope) serves session list/resume reads over two paths: the authoritative directory scan (`sessionIndexSource`, always correct, linear) and the minidb-backed derived read model (`IQueryStore` at `/cache/query-store`, keyset-paged, `O(log N + limit)`), gated by the `persistence_minidb_readmodel` flag (default ON; roll back via `KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL=false` or the `[experimental]` config section). The read model has an explicit lifecycle — `uninitialized → preparing → ready/degraded` via `prepare()`/`status()`; reads while preparing answer from the authoritative store immediately and fold the `ISessionIndexMirror` queue in for read-your-writes; the first list shares one single-flight authoritative scan with the initial projection. The query-store is structural-only — text-index definitions are rejected at definition level, so session operations never touch the global full-text index (`/search-index`, owned by kap-server's search surface). diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index f01b5a55d5..2d7dec4eed 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,7 +21,7 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (49 record types) +// Index (51 record types) // config.update profile persisted src/agent/profile/profileOps.ts // context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts // context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts @@ -66,6 +66,8 @@ // tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts // tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts // tools.update_store todo persisted src/session/todo/todoOps.ts +// tower_mode.enter tower persisted src/features/tower/towerOps.ts +// tower_mode.exit tower persisted src/features/tower/towerOps.ts // turn.cancel turn persisted src/agent/loop/turnOps.ts // turn.ended turn persisted src/agent/loop/turnOps.ts // turn.prompt turn persisted src/agent/loop/turnOps.ts @@ -619,6 +621,22 @@ interface ToolsUpdateStorePayload { value: any; } +/** + * model: tower · persisted · toEvent + * owner: src/features/tower/towerOps.ts + */ +interface TowerModeEnterPayload { + _name: 'tower_mode.enter'; +} + +/** + * model: tower · persisted · toEvent + * owner: src/features/tower/towerOps.ts + */ +interface TowerModeExitPayload { + _name: 'tower_mode.exit'; +} + /** * model: turn · persisted * owner: src/agent/loop/turnOps.ts @@ -771,6 +789,8 @@ interface WirePayloadMap { "tools.set_active_tools": ToolsSetActiveToolsPayload; "tools.unregister_user_tool": ToolsUnregisterUserToolPayload; "tools.update_store": ToolsUpdateStorePayload; + "tower_mode.enter": TowerModeEnterPayload; + "tower_mode.exit": TowerModeExitPayload; "turn.cancel": TurnCancelPayload; "turn.ended": TurnEndedPayload; "turn.prompt": TurnPromptPayload; diff --git a/packages/agent-core-v2/src/agent/skill/skill.ts b/packages/agent-core-v2/src/agent/skill/skill.ts index e512195a94..d616c0606b 100644 --- a/packages/agent-core-v2/src/agent/skill/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/skill.ts @@ -3,9 +3,10 @@ * * `SkillActivationInput` carries the slash name and raw args, plus optional * edge-resolved attachment parts (`content`) that the activation appends after - * the rendered skill prompt in its user message. `IAgentSkillService` starts - * the activation turn (`activate`) and records model-tool activations without - * a turn (`recordModelToolActivation`). Bound at Agent scope. + * the rendered skill prompt in its user message. `IAgentSkillService` + * delivers activations (`activate` — steered into the running turn when busy, + * launched as a fresh turn when idle) and records model-tool activations + * without a turn (`recordModelToolActivation`). Bound at Agent scope. */ import { createDecorator } from "#/_base/di/instantiation"; diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index 6148e52268..218d51e2a5 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -4,14 +4,16 @@ * Resolves skills from the session catalog, renders the activation prompt, * records the activation as a `skill.activate` fact through `wire.dispatch` * (a stateless, identity-apply Op), derives the `skill.activated` event - * through the Op's `toEvent`, drives user-slash activations into a new turn via - * `prompt` (attachment parts from the caller ride the same user message after - * the rendered prompt), settles `{turn_id}` for the caller, persists the - * derived title/lastPrompt through `sessionMetadata` for the main agent only - * (publishing the live update through `event`), and reports `skill_invoked` / - * `flow_invoked` through `telemetry`. `wire.replay` reapplies the fact as a - * no-op, so neither the event nor telemetry fires on resume (matching the - * former `restoring` guard). Bound at Agent scope. + * through the Op's `toEvent`, and delivers user-slash activations through + * `prompt.inject` — steered into the running turn when one is active, + * launched as a fresh turn when idle, exactly the queue/steer equivalence of + * plain user input (attachment parts from the caller ride the same user + * message after the rendered prompt). It settles `{turn_id}` for the caller, + * persists the derived title/lastPrompt through `sessionMetadata` for the + * main agent only (publishing the live update through `event`), and reports + * `skill_invoked` / `flow_invoked` through `telemetry`. `wire.replay` + * reapplies the fact as a no-op, so neither the event nor telemetry fires on + * resume (matching the former `restoring` guard). Bound at Agent scope. */ import { randomUUID } from 'node:crypto'; @@ -28,7 +30,7 @@ import { ErrorCodes, Error2 } from '#/errors'; import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types'; import { IAgentPromptService, type PromptLaunchResult } from '#/agent/prompt/prompt'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { Turn } from '#/agent/loop/loop'; +import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { IWireService } from '#/wire/wire'; import { IAgentSkillService, type SkillActivationInput } from './skill'; import { skillActivate } from './skillOps'; @@ -45,6 +47,7 @@ export class AgentSkillService extends Service implements IAgentSkillService { constructor( @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @IAgentPromptService private readonly prompt: IAgentPromptService, + @IAgentLoopService private readonly loop: IAgentLoopService, @IWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, @ISessionContext private readonly sessionContext: ISessionContext, @@ -136,6 +139,13 @@ export class AgentSkillService extends Service implements IAgentSkillService { toolCalls: [], origin, }; + // Plain-input equivalence, no opt-in: an activation that arrives while a + // turn is running steers into it at the next step boundary (the + // skill_activation origin rides the `turn.steer` record); with no running + // turn it queues as a fresh prompt turn exactly like normal input. + if (this.loop.status().state === 'running') { + return this.prompt.inject(message); + } return (await this.prompt.enqueue({ message })).launched; } diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index 069e756b46..2d1c600f50 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -28,6 +28,7 @@ declare module '#/app/event/eventBus' { 'agent.status.updated': { usage?: UsageStatus; swarmMode?: boolean; + towerMode?: boolean; planMode?: boolean; model?: string; thinkingEffort?: string; diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/builtin.ts b/packages/agent-core-v2/src/app/skillCatalog/builtin/builtin.ts index dbbb9dad49..6b668a9403 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/builtin.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/builtin.ts @@ -2,7 +2,9 @@ * `skillCatalog` domain — builtin skill registration. * * Code-defined builtin skills are constants (not discovered from storage), so - * they bypass `ISkillDiscovery`: `BUILTIN_SKILLS` feeds the builtin + * they bypass `ISkillDiscovery`: `BUILTIN_SKILLS` plus the feature-authored + * contributions registered through `registerBuiltinSkill` (./registry — the + * "import = register" channel, e.g. the tower skill) feed the builtin * `ISkillSource`. * * `visibleBuiltinSkills` is the one place that decides which of them the @@ -13,10 +15,12 @@ */ import type { SkillDefinition } from '#/app/skillCatalog/types'; + import { CHECK_KIMI_CODE_DOCS_SKILL } from './check-kimi-code-docs'; import { CUSTOM_THEME_SKILL } from './custom-theme'; import { IMPORT_FROM_CC_CODEX_SKILL } from './import-from-cc-codex'; import { MCP_CONFIG_SKILL } from './mcp-config'; +import { getBuiltinSkillContributions } from './registry'; import { SUB_SKILL_CONSOLIDATE, SUB_SKILL_PARENT, @@ -38,8 +42,9 @@ export const BUILTIN_SKILLS: readonly SkillDefinition[] = [ ]; export function visibleBuiltinSkills(productSkillsEnabled: boolean): readonly SkillDefinition[] { - if (productSkillsEnabled) return BUILTIN_SKILLS; - return BUILTIN_SKILLS.filter((skill) => skill.productSpecific !== true); + const all = [...BUILTIN_SKILLS, ...getBuiltinSkillContributions()]; + if (productSkillsEnabled) return all; + return all.filter((skill) => skill.productSpecific !== true); } export { diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/registry.ts b/packages/agent-core-v2/src/app/skillCatalog/builtin/registry.ts new file mode 100644 index 0000000000..71a3f506ff --- /dev/null +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/registry.ts @@ -0,0 +1,34 @@ +/** + * `skillCatalog` domain — module-level builtin-skill contribution registry. + * + * Feature-authored builtin skills contribute themselves at module load via + * `registerBuiltinSkill(skill)` — the same "import = register" pattern used + * by `registerAgentToolService` for agent tools and `registerAgentProfile` + * for agent profiles. `visibleBuiltinSkills` folds these with the + * code-defined `BUILTIN_SKILLS`, so a feature (e.g. tower) ships its builtin + * skill without editing the builtin module. Uniqueness is enforced by + * `name`: later registrations replace earlier ones, so tests can override a + * built-in by re-registering. + */ + +import type { SkillDefinition } from '#/app/skillCatalog/types'; + +const _builtinSkillContributions: SkillDefinition[] = []; + +export function registerBuiltinSkill(skill: SkillDefinition): void { + const existingIndex = _builtinSkillContributions.findIndex( + (candidate) => candidate.name === skill.name, + ); + if (existingIndex >= 0) { + _builtinSkillContributions.splice(existingIndex, 1); + } + _builtinSkillContributions.push(skill); +} + +export function getBuiltinSkillContributions(): readonly SkillDefinition[] { + return _builtinSkillContributions; +} + +export function _clearBuiltinSkillContributionsForTests(): void { + _builtinSkillContributions.length = 0; +} diff --git a/packages/agent-core-v2/src/features/tower/protocol/frontmatter.ts b/packages/agent-core-v2/src/features/tower/protocol/frontmatter.ts new file mode 100644 index 0000000000..26d6161583 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/frontmatter.ts @@ -0,0 +1,42 @@ +/** + * `tower` domain (protocol) — minimal YAML-frontmatter codec for tower comms + * files. + * + * Tower files carry a flat string→string map between `---` fences, one + * `key: value` per line. The store is the only writer, so values are + * guaranteed single-line (enforced here); parsing accepts exactly what + * `renderFrontmatter` produces. + */ + +const FENCE = '---'; + +export function renderFrontmatter(fields: Readonly>): string { + const lines = [FENCE]; + for (const [key, value] of Object.entries(fields)) { + if (/[\r\n]/.test(value)) { + throw new Error(`frontmatter value for "${key}" must be single-line`); + } + lines.push(`${key}: ${value}`); + } + lines.push(FENCE); + return lines.join('\n'); +} + +export function parseFrontmatter(text: string): { + readonly fields: Record; + readonly body: string; +} { + const lines = text.split(/\r?\n/); + if (lines[0]?.trim() !== FENCE) return { fields: {}, body: text }; + const close = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE); + if (close === -1) return { fields: {}, body: text }; + + const fields: Record = {}; + for (const line of lines.slice(1, close)) { + const separator = line.indexOf(':'); + if (separator <= 0) continue; + const key = line.slice(0, separator).trim(); + fields[key] = line.slice(separator + 1).trim(); + } + return { fields, body: lines.slice(close + 1).join('\n').trim() }; +} diff --git a/packages/agent-core-v2/src/features/tower/protocol/git.ts b/packages/agent-core-v2/src/features/tower/protocol/git.ts new file mode 100644 index 0000000000..078c91591a --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -0,0 +1,112 @@ +/** + * `tower` domain (protocol) — git plumbing for tower. Engine-internal + * operations (worktree add/remove, merge, diff) run through `execFile` with a + * hard timeout — these are not agent-invoked shell commands, so they do not + * go through the Bash tool. + */ + +import { execFile } from 'node:child_process'; + +const GIT_TIMEOUT_MS = 60_000; + +export class GitError extends Error { + constructor( + readonly args: readonly string[], + readonly stderr: string, + ) { + super(`git ${args.join(' ')} failed: ${stderr.trim() || 'unknown error'}`); + this.name = 'GitError'; + } +} + +export async function git(cwd: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + [...args], + { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, + (error, stdout, stderr) => { + if (error !== null) { + reject(new GitError(args, stderr || error.message)); + return; + } + resolve(stdout.trimEnd()); + }, + ); + }); +} + +/** `git` that returns null instead of throwing when the command fails. */ +export async function tryGit(cwd: string, args: readonly string[]): Promise { + try { + return await git(cwd, args); + } catch { + return null; + } +} + +export async function isInsideRepo(cwd: string): Promise { + return (await tryGit(cwd, ['rev-parse', '--is-inside-work-tree'])) === 'true'; +} + +export async function hasAnyCommit(cwd: string): Promise { + return (await tryGit(cwd, ['rev-list', '-n', '1', '--all'])) !== null; +} + +export async function currentBranch(cwd: string): Promise { + const branch = await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']); + if (branch === 'HEAD') throw new Error('cannot determine base branch from a detached HEAD'); + return branch; +} + +export async function branchTip(cwd: string, ref: string): Promise { + return git(cwd, ['rev-parse', ref]); +} + +export async function branchExists(cwd: string, branch: string): Promise { + return ( + (await tryGit(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== null + ); +} + +export async function worktreeAdd( + cwd: string, + path: string, + branch: string, + base: string, +): Promise { + if (await branchExists(cwd, branch)) { + await git(cwd, ['worktree', 'add', path, branch]); + return; + } + await git(cwd, ['worktree', 'add', path, '-b', branch, base]); +} + +/** + * Removal is always `--force`: the caller's dirty check is the data-loss gate. + * A plain `git worktree remove` additionally refuses clean worktrees that + * contain initialized submodules, which must not strand a clean teardown. + */ +export async function worktreeRemove(cwd: string, path: string): Promise { + await git(cwd, ['worktree', 'remove', '--force', path]); +} + +export async function isWorktreeDirty(path: string): Promise { + const status = await tryGit(path, ['status', '--porcelain']); + return status !== null && status.trim().length > 0; +} + +export async function mergeNoFf(cwd: string, branch: string): Promise { + await git(cwd, ['merge', '--no-ff', branch]); + return branchTip(cwd, 'HEAD'); +} + +/** Changed files of `ref` relative to `base` (three-dot, i.e. since merge-base). */ +export async function diffNameOnly( + cwd: string, + base: string, + ref: string, +): Promise { + const out = await git(cwd, ['diff', '--name-only', `${base}...${ref}`]); + return out.length === 0 ? [] : out.split('\n').filter((line) => line.trim().length > 0); +} diff --git a/packages/agent-core-v2/src/features/tower/protocol/index.ts b/packages/agent-core-v2/src/features/tower/protocol/index.ts new file mode 100644 index 0000000000..a58bf65f37 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/index.ts @@ -0,0 +1,6 @@ +export * from './frontmatter'; +export * from './git'; +export * from './paths'; +export * from './repoRoot'; +export * from './store'; +export * from './types'; diff --git a/packages/agent-core-v2/src/features/tower/protocol/paths.ts b/packages/agent-core-v2/src/features/tower/protocol/paths.ts new file mode 100644 index 0000000000..336d5fd0bf --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/paths.ts @@ -0,0 +1,84 @@ +/** + * `tower` domain (protocol) — workspace layout and file naming. Every comms + * file name is built here — agents never construct paths by hand. + */ + +export const TOWER_ROOT = '.tower'; +export const COMMS_DIR = `${TOWER_ROOT}/comms`; +export const INBOX_DIR = `${COMMS_DIR}/inbox`; +export const FINDINGS_DIR = `${COMMS_DIR}/findings`; +export const REVIEWS_DIR = `${COMMS_DIR}/reviews`; +export const MISSIONS_DIR = `${COMMS_DIR}/missions`; +export const LOG_DIR = `${COMMS_DIR}/log`; +export const WORKTREES_DIR = `${TOWER_ROOT}/worktrees`; + +export const STATE_FILE = `${COMMS_DIR}/state.json`; +export const ACTIVITY_LOG = `${LOG_DIR}/activity.log`; +export const MISSIONS_INDEX = `${COMMS_DIR}/MISSIONS.md`; + +export const TOWER_NAME = 'tower'; +export const BROADCAST_NAME = 'all'; + +/** Local YYYYMMDD, used at the start of inbox/finding file names. */ +export function dateStamp(now = new Date()): string { + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + return `${y}${m}${d}`; +} + +/** `YYYY-MM-DD` for review frontmatter. */ +export function dateDash(now = new Date()): string { + const stamp = dateStamp(now); + return `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}`; +} + +/** + * Filesystem-safe slug: lowercase, alnum runs joined by `-`. CJK and other + * non-ASCII letters are dropped so names stay greppable everywhere. + */ +export function slugify(text: string, maxLength = 60): string { + const slug = text + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-+|-+$/g, '') + .slice(0, maxLength) + .replaceAll(/-+$/g, ''); + return slug.length > 0 ? slug : 'item'; +} + +/** Branch/PR targets become filename segments: `feat/x` → `feat-x`, `#12` → `pr12`. */ +export function targetSlug(target: string): string { + const cleaned = target.trim().replace(/^#/, 'pr'); + return slugify(cleaned.replaceAll(/[/#]+/g, '-')); +} + +export function inboxFileName(input: { + readonly from: string; + readonly to: string; + readonly subject: string; + readonly now?: Date; +}): string { + return `${dateStamp(input.now)}-${slugify(input.from, 30)}-${slugify(input.to, 30)}-${slugify(input.subject)}.md`; +} + +export function findingFileName(input: { + readonly agent: string; + readonly type: string; + readonly slug: string; + readonly now?: Date; +}): string { + return `${dateStamp(input.now)}-${slugify(input.agent, 30)}-${slugify(input.type, 12)}-${slugify(input.slug)}.md`; +} + +export function reviewFileName(input: { + readonly target: string; + readonly reviewer: string; + readonly round: number; +}): string { + return `review-${targetSlug(input.target)}-${slugify(input.reviewer, 30)}-r${input.round}.md`; +} + +export function missionFileName(id: string, slug: string): string { + return `${id}-${slugify(slug)}.md`; +} diff --git a/packages/agent-core-v2/src/features/tower/protocol/repoRoot.ts b/packages/agent-core-v2/src/features/tower/protocol/repoRoot.ts new file mode 100644 index 0000000000..8b1451fba9 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/repoRoot.ts @@ -0,0 +1,19 @@ +/** + * `tower` domain (protocol) — maps a caller's working directory back to the + * main checkout that holds `.tower/`. + * + * Tower worktrees always live at `/.tower/worktrees/`, so a + * caller anchored inside one maps back to the main checkout by convention — + * no state lookup needed (which would be circular: reading state requires the + * store root). + */ + +import { WORKTREES_DIR } from './paths'; + +export function resolveTowerRepoRoot(cwd: string): string { + const normalized = cwd.replaceAll('\\', '/'); + const marker = `/${WORKTREES_DIR}/`; + const index = normalized.indexOf(marker); + if (index === -1) return cwd; + return cwd.slice(0, index); +} diff --git a/packages/agent-core-v2/src/features/tower/protocol/store.ts b/packages/agent-core-v2/src/features/tower/protocol/store.ts new file mode 100644 index 0000000000..d049a4ce92 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/store.ts @@ -0,0 +1,1068 @@ +/** + * `tower` domain (protocol) — `TowerStore`, the code-enforced half of the + * tower protocol. + * + * Every comms artifact (inbox message, finding, review, mission file, + * MISSIONS.md, activity log line) is produced HERE, never by an agent writing + * files by hand. That is what makes the protocol invariants actual + * invariants: file naming, frontmatter shape, recipient validity, review + * rounds, the merge gate, and the exact activity-log format are not subject + * to model discipline. + * + * State lives in `.tower/comms/state.json` (machine truth). `MISSIONS.md` + * and `missions/*.md` are regenerated human views after every mutation. + * All store instances in the process share one activity log via append-only + * `fs.appendFile` — one line per action, written immediately. + */ + +import { randomUUID } from 'node:crypto'; +import { appendFile, mkdir, open, readFile, readdir, rename, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import picomatch from 'picomatch'; + +import { parseFrontmatter, renderFrontmatter } from './frontmatter'; +import { + branchExists, + branchTip, + currentBranch, + diffNameOnly, + hasAnyCommit, + isInsideRepo, + isWorktreeDirty, + mergeNoFf, + worktreeAdd, + worktreeRemove, +} from './git'; +import { + ACTIVITY_LOG, + BROADCAST_NAME, + FINDINGS_DIR, + INBOX_DIR, + LOG_DIR, + MISSIONS_DIR, + MISSIONS_INDEX, + REVIEWS_DIR, + STATE_FILE, + TOWER_NAME, + WORKTREES_DIR, + dateDash, + findingFileName, + inboxFileName, + missionFileName, + reviewFileName, + slugify, + targetSlug, +} from './paths'; +import type { + TowerFindingSeverity, + TowerFindingType, + TowerInboxItem, + TowerMission, + TowerMissionKind, + TowerMissionStatus, + TowerReviewInfo, + TowerRosterEntry, + TowerState, +} from './types'; + +export class TowerProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'TowerProtocolError'; + } +} + +export interface TowerInitResult { + readonly base: string; + readonly created: boolean; + /** + * Roster names retired while adopting a workspace last driven by a + * different session. Empty on creation and on same-session re-init. + */ + readonly retiredAgents: readonly string[]; +} + +export interface TowerPlanInput { + readonly title: string; + readonly scope: readonly string[]; + readonly tasks?: readonly string[]; + readonly deps?: readonly string[]; + /** Defaults to `build`. `survey` missions are read-only and reserve no scope. */ + readonly kind?: TowerMissionKind; +} + +export interface TowerSendInput { + readonly to: string; + readonly subject: string; + readonly body: string; + readonly scope?: string; + readonly action?: string; + readonly consentRef?: string; +} + +export interface TowerFindingInput { + readonly type: TowerFindingType; + readonly title: string; + readonly severity?: TowerFindingSeverity; + readonly summary: string; + readonly location?: string; + readonly details: string; + readonly suggestedFix: string; +} + +export interface TowerReviewInput { + readonly target: string; + readonly status: string; + readonly merge: string; + readonly findings: string; + readonly checks?: readonly string[]; + readonly decision: string; +} + +export interface TowerMissionPatch { + readonly status?: TowerMissionStatus; + readonly note?: string; + readonly blocker?: string; + readonly clearBlockers?: boolean; + readonly taskDone?: string; + /** Tower-only: assign the roster agent that owns this mission. */ + readonly owner?: string; + /** Tower-only: replace the mission's scope globs (logged; widens the merge gate). */ + readonly scope?: readonly string[]; +} + +const FINDING_TYPES: readonly TowerFindingType[] = ['bug', 'improve', 'vuln', 'idea']; +const STATUS_EMOJI: Record = { + planned: '🟡', + active: '🔵', + completed: '🟢', + blocked: '🔴', + paused: '⏸️', + merged: '✅', +}; + +export class TowerStore { + /** Absolute path of the main checkout (the session working directory). */ + constructor(readonly repoRoot: string) {} + + // --------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------- + + async isInitialized(): Promise { + try { + await readFile(this.abs(STATE_FILE), 'utf8'); + return true; + } catch { + return false; + } + } + + /** + * Create the `.tower/` skeleton. Safe to call twice — an existing + * workspace is reported, never reset. When the existing workspace was last + * driven by a *different* session it is adopted instead: roster entries the + * current session did not spawn are retired (engine agent ids are + * session-scoped, so after a restart the dead entries would alias this + * session's freshly issued `agent-N` ids), missions/worktrees survive, and + * an `adopt` line marks the session boundary in the activity log. + */ + async init(sessionId?: string): Promise { + if (!(await isInsideRepo(this.repoRoot))) { + throw new TowerProtocolError( + 'tower needs a git repository (the session working directory is not inside one)', + ); + } + if (!(await hasAnyCommit(this.repoRoot))) { + throw new TowerProtocolError( + 'the repository has no commits yet — create an initial commit first', + ); + } + if (await this.isInitialized()) { + const state = await this.load(); + const retiredAgents = await this.adoptForeignRoster(state, sessionId); + return { base: state.base, created: false, retiredAgents }; + } + + for (const dir of [INBOX_DIR, FINDINGS_DIR, REVIEWS_DIR, MISSIONS_DIR, LOG_DIR, WORKTREES_DIR]) { + await mkdir(this.abs(dir), { recursive: true }); + } + await this.ensureGitExclude(); + + const base = await currentBranch(this.repoRoot); + const state: TowerState = { + version: 1, + base, + mode: 'branch', + createdAt: new Date().toISOString(), + sessionId, + roster: { agents: [] }, + missions: [], + }; + await this.save(state); + await writeFile(this.abs(ACTIVITY_LOG), '', 'utf8'); + await this.renderMissionsIndex(state); + await this.appendLog(TOWER_NAME, 'init', { mode: state.mode, base }, MISSIONS_INDEX); + return { base, created: true, retiredAgents: [] }; + } + + /** + * Retire roster entries spawned by other sessions and restamp the state + * with the current session id. The `adopt` log line is written on every + * session change — even with nothing to retire — so id collisions across + * the boundary stay attributable when reading the activity log. + */ + private async adoptForeignRoster( + state: TowerState, + sessionId: string | undefined, + ): Promise { + if (sessionId === undefined || state.sessionId === sessionId) return []; + const previous = state.sessionId; + const stale = state.roster.agents.filter((agent) => agent.sessionId !== sessionId); + state.roster.agents.splice( + 0, + state.roster.agents.length, + ...state.roster.agents.filter((agent) => agent.sessionId === sessionId), + ); + state.sessionId = sessionId; + await this.save(state); + await this.appendLog(TOWER_NAME, 'adopt', { + session: sessionId, + previous: previous ?? 'unknown', + retired: stale.length > 0 ? stale.map((agent) => agent.name).join(',') : undefined, + }); + return stale.map((agent) => agent.name); + } + + /** Add `.tower/` to `.git/info/exclude` (repo-local; tracked .gitignore stays untouched). */ + private async ensureGitExclude(): Promise { + const gitDir = (await readGitDir(this.repoRoot)) ?? join(this.repoRoot, '.git'); + const excludePath = join(gitDir, 'info', 'exclude'); + await mkdir(dirname(excludePath), { recursive: true }); + let existing = ''; + try { + existing = await readFile(excludePath, 'utf8'); + } catch { + // no exclude file yet + } + if (existing.split(/\r?\n/).some((line) => line.trim() === '.tower/')) return; + await appendFile(excludePath, `${existing.endsWith('\n') || existing.length === 0 ? '' : '\n'}.tower/\n`, 'utf8'); + } + + async load(): Promise { + let raw: string; + try { + raw = await readFile(this.abs(STATE_FILE), 'utf8'); + } catch { + throw new TowerProtocolError( + 'tower is not initialized in this repository — run TowerInit first', + ); + } + const state = JSON.parse(raw) as TowerState; + // Backward compat: state files written before mission kinds existed are all builds. + for (const mission of state.missions) { + mission.kind ??= 'build'; + } + return state; + } + + private async save(state: TowerState): Promise { + const file = this.abs(STATE_FILE); + const tmp = `${file}.tmp`; + await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); + await rename(tmp, file); + } + + // --------------------------------------------------------------------- + // Activity log — the ONLY writer of activity.log lines. + // --------------------------------------------------------------------- + + async appendLog( + actor: string, + action: string, + details: Readonly> = {}, + ref?: string, + ): Promise { + const kv = Object.entries(details) + .filter((entry): entry is [string, string | number] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${value}`) + .join(' '); + const parts = [new Date().toISOString(), actor, action]; + if (kv.length > 0) parts.push(kv); + if (ref !== undefined) parts.push(`ref=${ref}`); + await appendFile(this.abs(ACTIVITY_LOG), `${parts.join(' ')}\n`, 'utf8'); + } + + async recentLog(lines: number): Promise { + let content = ''; + try { + content = await readFile(this.abs(ACTIVITY_LOG), 'utf8'); + } catch { + return []; + } + const all = content.split('\n').filter((line) => line.trim().length > 0); + return all.slice(-lines); + } + + // --------------------------------------------------------------------- + // Roster + // --------------------------------------------------------------------- + + resolveCallerName(state: TowerState, agentId: string): string { + if (agentId === 'main') return TOWER_NAME; + const entry = state.roster.agents.find((agent) => agent.agentId === agentId); + if (entry === undefined) { + throw new TowerProtocolError( + `agent "${agentId}" is not a tower participant — only spawned workers/reviewers and the tower can use tower tools`, + ); + } + return entry.name; + } + + findAgent(state: TowerState, name: string): TowerRosterEntry | undefined { + return state.roster.agents.find((agent) => agent.name === name); + } + + /** + * Register a spawned agent. Returns the existing entry when the name is + * already taken — callers implement "resume instead of duplicate spawn". + */ + findByName(state: TowerState, name: string): TowerRosterEntry | undefined { + return this.findAgent(state, name); + } + + async registerAgent(entry: TowerRosterEntry): Promise { + const state = await this.load(); + if (this.findAgent(state, entry.name) !== undefined) { + throw new TowerProtocolError(`tower agent name "${entry.name}" is already registered`); + } + state.roster.agents.push(entry); + await this.save(state); + } + + // --------------------------------------------------------------------- + // Missions + // --------------------------------------------------------------------- + + async plan(input: readonly TowerPlanInput[]): Promise { + if (input.length === 0) { + throw new TowerProtocolError('TowerPlan needs at least one mission'); + } + const state = await this.load(); + const startIndex = state.missions.length; + + const missions: TowerMission[] = input.map((item, index) => { + const n = startIndex + index + 1; + const slug = slugify(item.title, 40); + return { + id: `M${n}`, + title: item.title, + slug, + kind: item.kind ?? 'build', + scope: [...item.scope], + branch: `feat/${slug}`, + worktree: `wt-${n}`, + deps: item.deps ?? [], + status: 'planned', + tasks: (item.tasks ?? []).map((text) => ({ text, done: false })), + notes: [], + blockers: [], + }; + }); + + // Mission ids referenced by deps must exist (already planned or in this batch). + const knownIds = new Set([...state.missions.map((m) => m.id), ...missions.map((m) => m.id)]); + for (const mission of missions) { + for (const dep of mission.deps) { + if (!knownIds.has(dep)) { + throw new TowerProtocolError(`mission ${mission.id} depends on unknown mission "${dep}"`); + } + } + } + // Merged missions are history, not reservations: their scopes stay on + // record but must not block new missions from taking the same paths. + this.assertScopesDisjoint([ + ...state.missions.filter((m) => m.status !== 'merged'), + ...missions, + ]); + + state.missions.push(...missions); + await this.save(state); + await this.renderMissionsIndex(state); + for (const mission of missions) { + await this.renderMissionFile(mission); + } + await this.appendLog( + TOWER_NAME, + 'plan', + { missions: missions.map((m) => m.id).join(',') }, + MISSIONS_INDEX, + ); + return missions; + } + + /** + * Conservative overlap check over the scopes that reserve write access — + * i.e. `build` missions only. Survey scopes are informational and reserve + * nothing, so they never conflict. Two build scopes conflict when one is a + * path prefix of the other after stripping trailing `**` / `*` wildcards. + */ + private assertScopesDisjoint(missions: readonly TowerMission[]): void { + const scopes: Array<{ readonly id: string; readonly raw: string; readonly stem: string }> = []; + for (const mission of missions) { + if (mission.kind === 'survey') continue; + for (const raw of mission.scope) { + const stem = raw.replace(/\/\*\*?$/, '').replace(/\*$/, '').replace(/\/+$/, ''); + if (stem.length === 0) { + throw new TowerProtocolError( + `mission ${mission.id} scope "${raw}" covers the whole repo — narrow it down`, + ); + } + scopes.push({ id: mission.id, raw, stem }); + } + } + for (let i = 0; i < scopes.length; i++) { + for (let j = i + 1; j < scopes.length; j++) { + const a = scopes[i]!; + const b = scopes[j]!; + if (a.id === b.id) continue; + if (a.stem === b.stem || a.stem.startsWith(`${b.stem}/`) || b.stem.startsWith(`${a.stem}/`)) { + throw new TowerProtocolError( + `mission scopes overlap: ${a.id} ("${a.raw}") vs ${b.id} ("${b.raw}") — split the shared files into exactly one mission`, + ); + } + } + } + } + + async updateMission( + callerName: string, + id: string, + patch: TowerMissionPatch, + options: { readonly silent?: boolean } = {}, + ): Promise { + const state = await this.load(); + const mission = state.missions.find((m) => m.id === id); + if (mission === undefined) { + throw new TowerProtocolError(`unknown mission "${id}"`); + } + if (callerName !== TOWER_NAME) { + const caller = this.findAgent(state, callerName); + if (caller?.kind !== 'worker' || caller.missionId !== id) { + throw new TowerProtocolError( + `agent "${callerName}" does not own mission ${id} — workers update only their own mission file`, + ); + } + } + + // No-op patches (an unchanged status, nothing else) neither render nor + // log — e.g. a worker re-declaring `active` after the tower already set it. + const isNoOp = + patch.status === mission.status && + patch.note === undefined && + patch.blocker === undefined && + patch.clearBlockers === undefined && + patch.taskDone === undefined && + patch.owner === undefined && + patch.scope === undefined; + if (isNoOp) return mission; + + if (patch.owner !== undefined) { + if (callerName !== TOWER_NAME) { + throw new TowerProtocolError( + `agent "${callerName}" cannot assign mission ownership — only the tower sets owner`, + ); + } + mission.owner = patch.owner; + } + if (patch.scope !== undefined) { + if (callerName !== TOWER_NAME) { + throw new TowerProtocolError( + `agent "${callerName}" cannot change mission scope — only the tower widens a scope, and every change is logged`, + ); + } + this.assertScopesDisjoint([ + ...state.missions.filter((m) => m.id !== id && m.status !== 'merged'), + { ...mission, scope: [...patch.scope] }, + ]); + mission.scope = [...patch.scope]; + } + if (patch.status !== undefined) mission.status = patch.status; + if (patch.note !== undefined) mission.notes.push(patch.note); + if (patch.blocker !== undefined) { + mission.blockers.push(patch.blocker); + mission.status = 'blocked'; + } + if (patch.clearBlockers === true) mission.blockers = []; + if (patch.taskDone !== undefined) { + const task = mission.tasks.find((t) => !t.done && t.text.includes(patch.taskDone!)); + if (task === undefined) { + throw new TowerProtocolError( + `mission ${id} has no open task matching "${patch.taskDone}"`, + ); + } + task.done = true; + } + + await this.save(state); + await this.renderMissionsIndex(state); + await this.renderMissionFile(mission); + // Task ticks alone are not log-worthy: the mission file is their record. + // The activity log keeps state transitions and decision-shaped changes. + const taskTickOnly = + patch.taskDone !== undefined && + patch.status === undefined && + patch.note === undefined && + patch.blocker === undefined && + patch.clearBlockers === undefined && + patch.owner === undefined && + patch.scope === undefined; + if (!taskTickOnly && options.silent !== true) { + await this.appendLog(callerName, 'mission.update', { + id, + status: patch.status, + note: patch.note !== undefined ? 'added' : undefined, + blocker: patch.blocker !== undefined ? 'added' : undefined, + owner: patch.owner, + scope: patch.scope?.join(','), + }); + } + return mission; + } + + // --------------------------------------------------------------------- + // Inbox + // --------------------------------------------------------------------- + + async send(callerName: string, input: TowerSendInput): Promise { + const state = await this.load(); + const to = input.to.trim(); + if ( + to !== TOWER_NAME && + to !== BROADCAST_NAME && + this.findAgent(state, to) === undefined + ) { + const known = [TOWER_NAME, BROADCAST_NAME, ...state.roster.agents.map((a) => a.name)]; + throw new TowerProtocolError( + `unknown recipient "${to}" — address a roster agent, ${TOWER_NAME}, or ${BROADCAST_NAME} (known: ${known.join(', ')})`, + ); + } + if (to === callerName) { + throw new TowerProtocolError('cannot send an inbox message to yourself'); + } + + const frontmatter = renderFrontmatter({ + type: 'inbox', + message_id: randomUUID(), + from: callerName, + to, + subject: input.subject, + sent_at: new Date().toISOString(), + ...(input.scope !== undefined ? { scope: input.scope } : {}), + ...(input.action !== undefined ? { action: input.action } : {}), + ...(input.consentRef !== undefined ? { consent_ref: input.consentRef } : {}), + }); + const content = `${frontmatter}\n\n${input.body.trim()}\n`; + const baseName = inboxFileName({ from: callerName, to, subject: input.subject }); + const rel = await this.writeUnique(join(INBOX_DIR, baseName), content); + await this.appendLog(callerName, 'inbox.send', { to, subject: slugify(input.subject) }, rel); + return rel; + } + + /** Newest-first messages addressed to `callerName` or broadcast. The tower sees everything. */ + async readInbox(callerName: string, limit: number): Promise { + let files: string[]; + try { + files = await readdir(this.abs(INBOX_DIR)); + } catch { + return []; + } + const items: TowerInboxItem[] = []; + for (const file of files.filter((f) => f.endsWith('.md'))) { + const rel = join(INBOX_DIR, file); + let text: string; + try { + text = await readFile(this.abs(rel), 'utf8'); + } catch { + continue; + } + const { fields, body } = parseFrontmatter(text); + if (fields['type'] !== 'inbox') continue; + const to = fields['to'] ?? ''; + if (callerName !== TOWER_NAME && to !== callerName && to !== BROADCAST_NAME) continue; + items.push({ + file: rel, + from: fields['from'] ?? 'unknown', + to, + subject: fields['subject'] ?? '', + sentAt: fields['sent_at'] ?? '', + scope: fields['scope'], + action: fields['action'], + consentRef: fields['consent_ref'], + body, + }); + } + items.sort((a, b) => b.sentAt.localeCompare(a.sentAt)); + // Reads are not actions — the activity log records what participants DID, + // not what they looked at. No inbox.read line here. + return items.slice(0, Math.max(1, limit)); + } + + // --------------------------------------------------------------------- + // Findings + // --------------------------------------------------------------------- + + async fileFinding(callerName: string, input: TowerFindingInput): Promise { + if (!FINDING_TYPES.includes(input.type)) { + throw new TowerProtocolError( + `finding type must be one of ${FINDING_TYPES.join(' | ')}`, + ); + } + const state = await this.load(); + const caller = this.findAgent(state, callerName); + const mission = + caller?.missionId !== undefined + ? state.missions.find((m) => m.id === caller.missionId) + : undefined; + + const lines = [ + `# Finding: ${input.title}`, + '', + `**Date**: ${dateDash().replaceAll('-', '')}`, + `**Agent**: ${callerName}`, + `**Type**: ${input.type}`, + `**Severity**: ${input.severity ?? 'medium'}`, + `**Mission**: ${mission === undefined ? '(none)' : `${mission.id} — ${mission.title}`}`, + '', + '---', + '', + '## Summary', + input.summary.trim(), + '', + '## Location', + (input.location ?? '(not specified)').trim(), + '', + '## Details', + input.details.trim(), + '', + '## Suggested Fix / Action', + input.suggestedFix.trim(), + '', + '## Why Not Fixed Directly', + mission === undefined + ? 'This finding is outside the reporting agent’s assignment. Assigning to the control tower for routing.' + : `This finding is outside the scope of mission ${mission.id} (${mission.scope.join(', ')}). Fixing it directly would violate scope isolation. Assigning to the control tower for routing.`, + '', + '---', + '', + `*Filed by tower agent ${callerName} via \`${FINDINGS_DIR}/\`*`, + '', + ]; + const baseName = findingFileName({ + agent: callerName, + type: input.type, + slug: input.title, + }); + const rel = await this.writeUnique(join(FINDINGS_DIR, baseName), lines.join('\n')); + await this.appendLog(callerName, 'finding.file', { type: input.type, slug: slugify(input.title) }, rel); + return rel; + } + + // --------------------------------------------------------------------- + // Reviews + // --------------------------------------------------------------------- + + async submitReview(callerName: string, input: TowerReviewInput): Promise { + const state = await this.load(); + if (callerName !== TOWER_NAME) { + const caller = this.findAgent(state, callerName); + if (caller?.kind !== 'reviewer' || caller.reviewTarget !== input.target) { + throw new TowerProtocolError( + `agent "${callerName}" is not an assigned reviewer for "${input.target}"`, + ); + } + } + if (!/^(clean|p[12]-\d+items)$/.test(input.status)) { + throw new TowerProtocolError( + `review status must be clean | p1-Nitems | p2-Nitems, got "${input.status}"`, + ); + } + if (!['merge', 'fix-then-merge', 'hold'].includes(input.merge)) { + throw new TowerProtocolError( + `review merge verdict must be merge | fix-then-merge | hold, got "${input.merge}"`, + ); + } + + const existing = await this.reviewsFor(input.target); + const myRounds = existing.filter((r) => r.reviewer === callerName).length; + const round = myRounds + 1; + const reviewedCommit = await branchTip(this.repoRoot, input.target); + + const frontmatter = renderFrontmatter({ + date: dateDash(), + reviewer: callerName, + target: input.target, + round: String(round), + status: input.status, + merge: input.merge, + reviewed_commit: reviewedCommit, + }); + const checks = (input.checks ?? []).map((c) => `- [x] ${c}`).join('\n'); + const content = [ + frontmatter, + '', + '## Findings', + '', + input.findings.trim(), + '', + '## Checks', + checks.length > 0 ? checks : '- [x] (reviewer reported no formal checks)', + '', + '## Decision', + input.decision.trim(), + '', + ].join('\n'); + + const rel = await this.writeUnique( + join(REVIEWS_DIR, reviewFileName({ target: input.target, reviewer: callerName, round })), + content, + ); + await this.appendLog( + callerName, + 'review.write', + { target: input.target, round, verdict: input.status, reviewed: reviewedCommit.slice(0, 7) }, + rel, + ); + return rel; + } + + async reviewsFor(target: string): Promise { + let files: string[]; + try { + files = await readdir(this.abs(REVIEWS_DIR)); + } catch { + return []; + } + const prefix = `review-${targetSlug(target)}-`; + const reviews: TowerReviewInfo[] = []; + for (const file of files.filter((f) => f.startsWith(prefix) && f.endsWith('.md'))) { + const rel = join(REVIEWS_DIR, file); + let text: string; + try { + text = await readFile(this.abs(rel), 'utf8'); + } catch { + continue; + } + const { fields } = parseFrontmatter(text); + const round = Number.parseInt(fields['round'] ?? '', 10); + if (Number.isNaN(round)) continue; + reviews.push({ + reviewer: fields['reviewer'] ?? 'unknown', + target: fields['target'] ?? target, + round, + status: fields['status'] ?? '', + merge: fields['merge'] ?? '', + reviewedCommit: fields['reviewed_commit'] ?? '', + date: fields['date'] ?? '', + file: rel, + }); + } + reviews.sort((a, b) => a.round - b.round); + return reviews; + } + + async latestReview(target: string): Promise { + const reviews = await this.reviewsFor(target); + return reviews.at(-1); + } + + // --------------------------------------------------------------------- + // Merge — the hard gate. The tower LLM decides WHEN to call this; the + // gate itself decides WHETHER it happens. + // --------------------------------------------------------------------- + + async merge(branch: string): Promise<{ + readonly mergeCommit: string; + readonly conflictsWith: ReadonlyArray<{ readonly branch: string; readonly files: readonly string[] }>; + /** True when a read-only survey closed without a git merge. */ + readonly noop?: boolean; + }> { + const state = await this.load(); + const mission = state.missions.find((m) => m.branch === branch); + if (mission === undefined) { + throw new TowerProtocolError(`no tower mission owns branch "${branch}"`); + } + // A blocked merge is a decision with a reason — it belongs in the + // activity log just as much as a successful one. + const block = async (reason: string, message: string): Promise => { + await this.appendLog(TOWER_NAME, 'merge.blocked', { branch, reason }); + return new TowerProtocolError(message); + }; + + const unmergedDeps = mission.deps.filter((dep) => { + const depMission = state.missions.find((m) => m.id === dep); + return depMission !== undefined && depMission.status !== 'merged'; + }); + if (unmergedDeps.length > 0) { + throw await block( + 'deps-unmerged', + `merge blocked: dependencies not merged yet (${unmergedDeps.join(', ')}) — merge in Dependency Flow order`, + ); + } + + // Survey missions are read-only: a clean (zero-diff) branch closes with a + // noop merge — no review, no git ceremony. Any change on the branch is a + // read-only violation the tower must investigate. + if (mission.kind === 'survey') { + const changed = await diffNameOnly(this.repoRoot, state.base, branch); + if (changed.length > 0) { + throw await block( + 'read-only-survey', + `merge blocked: survey mission ${mission.id} is read-only but ${branch} has ${String(changed.length)} changed file(s): ${changed.slice(0, 5).join(', ')} — investigate the worker; if the changes are worth keeping, move them onto a build mission's branch`, + ); + } + mission.status = 'merged'; + await this.save(state); + await this.renderMissionsIndex(state); + await this.renderMissionFile(mission); + const tip = await branchTip(this.repoRoot, state.base); + await this.appendLog(TOWER_NAME, 'merge.noop', { branch, kind: 'survey' }); + return { mergeCommit: tip, conflictsWith: [], noop: true }; + } + + const review = await this.latestReview(branch); + if (review === undefined) { + throw await block( + 'no-review', + `merge blocked: ${branch} has no review — assign a reviewer first`, + ); + } + if (review.status !== 'clean') { + throw await block( + 'not-clean', + `merge blocked: latest review (round ${review.round} by ${review.reviewer}) is "${review.status}" — a clean round is required`, + ); + } + const tip = await branchTip(this.repoRoot, branch); + if (review.reviewedCommit !== tip) { + throw await block( + 'tip-moved', + `merge blocked: ${branch} moved since the clean review (reviewed ${review.reviewedCommit.slice(0, 7)}, tip ${tip.slice(0, 7)}) — re-review required`, + ); + } + + // Scope isolation, enforced: every file the branch changed must fall + // inside the mission's declared scope globs (picomatch semantics — `**` + // crosses directories). A legitimate expansion goes through a tower + // TowerMission scope update first, which is logged. + const changed = await diffNameOnly(this.repoRoot, state.base, branch); + const outOfScope = changed.filter( + (file) => !mission.scope.some((glob) => picomatch.isMatch(file, glob)), + ); + if (outOfScope.length > 0) { + throw await block( + 'out-of-scope', + `merge blocked: ${branch} changed files outside mission ${mission.id} scope (${mission.scope.join(', ')}): ${outOfScope.join(', ')} — the tower must widen the mission scope (TowerMission scope patch) or revert those changes`, + ); + } + + // The merge lands wherever the main checkout currently points: refuse + // when it has moved off the recorded base since TowerInit (a hotfix + // branch, a detached HEAD) — otherwise the mission would be marked + // merged while the base branch never received it. + let checkedOut: string; + try { + checkedOut = await currentBranch(this.repoRoot); + } catch { + throw await block( + 'base-mismatch', + `merge blocked: the main checkout is in a detached HEAD state — check out the recorded base branch "${state.base}" before merging; nothing was merged`, + ); + } + if (checkedOut !== state.base) { + throw await block( + 'base-mismatch', + `merge blocked: the main checkout is on "${checkedOut}", not the recorded base "${state.base}" — switch it back (\`git checkout ${state.base}\`) and retry; nothing was merged`, + ); + } + + const mergeCommit = await mergeNoFf(this.repoRoot, branch); + mission.status = 'merged'; + + // Informational: unmerged branches that touched the same files now likely + // conflict with the new base. The tower tells them to rebase — their tip + // moves, and the reviewed_commit gate then forces a re-review. + const changedSet = new Set(changed); + const conflictsWith: Array<{ readonly branch: string; readonly files: readonly string[] }> = []; + for (const other of state.missions) { + if (other.branch === branch || other.status === 'merged') continue; + // Planned missions may have no branch yet (never spawned) — nothing to conflict with. + if (!(await branchExists(this.repoRoot, other.branch))) continue; + const otherChanged = await diffNameOnly(this.repoRoot, state.base, other.branch); + const overlap = otherChanged.filter((file) => changedSet.has(file)); + if (overlap.length > 0) { + conflictsWith.push({ branch: other.branch, files: overlap }); + } + } + + await this.save(state); + await this.renderMissionsIndex(state); + await this.renderMissionFile(mission); + await this.appendLog(TOWER_NAME, 'merge', { branch, base: state.base, merge_commit: mergeCommit.slice(0, 7) }); + return { mergeCommit, conflictsWith }; + } + + // --------------------------------------------------------------------- + // Worktrees / teardown + // --------------------------------------------------------------------- + + async addWorktree(worktree: string, branch: string, base: string): Promise { + const rel = join(WORKTREES_DIR, worktree); + await worktreeAdd(this.repoRoot, this.abs(rel), branch, base); + await this.appendLog(TOWER_NAME, 'worktree.add', { worktree, branch, base }); + return rel; + } + + async teardown(options: { readonly force?: boolean } = {}): Promise { + const state = await this.load(); + const report: string[] = []; + for (const mission of state.missions) { + const rel = join(WORKTREES_DIR, mission.worktree); + const absPath = this.abs(rel); + if (await isWorktreeDirty(absPath)) { + if (options.force !== true) { + report.push(`kept ${rel} (uncommitted changes — rerun with force to remove)`); + await this.appendLog(TOWER_NAME, 'worktree.keep', { + worktree: mission.worktree, + reason: 'uncommitted-changes', + }); + continue; + } + } + try { + await worktreeRemove(this.repoRoot, absPath); + report.push(`removed ${rel}`); + await this.appendLog(TOWER_NAME, 'worktree.remove', { worktree: mission.worktree }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + report.push(`failed to remove ${rel}: ${reason}`); + await this.appendLog(TOWER_NAME, 'worktree.remove.failed', { + worktree: mission.worktree, + reason, + }); + } + } + await this.appendLog(TOWER_NAME, 'teardown', { force: options.force === true ? 'yes' : undefined }); + return report; + } + + // --------------------------------------------------------------------- + // Human views (generated, never hand-edited) + // --------------------------------------------------------------------- + + private async renderMissionsIndex(state: TowerState): Promise { + const rows = state.missions.map( + (m) => + `| ${m.id} | ${m.title} | ${m.branch} | ${m.worktree} | ${STATUS_EMOJI[m.status]} | ${m.owner ?? '—'} |`, + ); + const deps = state.missions + .flatMap((m) => m.deps.map((dep) => `${dep} → ${m.id}`)) + .join('\n'); + const scopes = state.missions + .map((m) => `- ${m.id}${m.kind === 'survey' ? ' (survey — informational, reserves nothing)' : ''}: ${m.scope.join(', ')}`) + .join('\n'); + const content = [ + '# MISSIONS', + '', + '', + '', + '| ID | Mission | Branch | Worktree | Status | Owner |', + '| -- | ------- | ------ | -------- | ------ | ----- |', + ...rows, + '', + 'Status: 🟡 planned · 🔵 active · 🟢 completed · 🔴 blocked · ⏸️ paused · ✅ merged', + `Mode: ${state.mode} — Base: ${state.base}`, + '', + '## Dependency Flow', + deps.length > 0 ? deps : '(none)', + '', + '## Scope Map', + scopes.length > 0 ? scopes : '(none)', + '', + ].join('\n'); + await writeFile(this.abs(MISSIONS_INDEX), content, 'utf8'); + } + + private async renderMissionFile(mission: TowerMission): Promise { + const rel = join(MISSIONS_DIR, missionFileName(mission.id, mission.slug)); + const content = [ + `# Mission ${mission.id}: ${mission.title}${mission.kind === 'survey' ? ' 🔍 (read-only survey)' : ''}`, + '', + '', + '', + '| Branch | Worktree | Status | Scope | Owner |', + '| ------ | -------- | ------ | ----- | ----- |', + `| ${mission.branch} | ${mission.worktree} | ${STATUS_EMOJI[mission.status]} | ${mission.scope.join(', ')} | ${mission.owner ?? '—'} |`, + '', + '## Tasks', + ...(mission.tasks.length > 0 + ? mission.tasks.map((t) => `- [${t.done ? 'x' : ' '}] ${t.text}`) + : ['- [ ] (no tasks recorded)']), + '', + '## Dependencies', + mission.deps.length > 0 ? mission.deps.join(', ') : '(none)', + '', + '## Blockers', + ...(mission.blockers.length > 0 ? mission.blockers.map((b) => `- ${b}`) : ['- (none)']), + '', + '## Notes', + ...(mission.notes.length > 0 ? mission.notes.map((n) => `- ${n}`) : ['- (none)']), + '', + ].join('\n'); + await writeFile(this.abs(rel), content, 'utf8'); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + abs(rel: string): string { + return join(this.repoRoot, rel); + } + + /** Exclusive-create write; on a name clash appends `-2`, `-3`, … before the extension. */ + private async writeUnique(rel: string, content: string): Promise { + const dot = rel.lastIndexOf('.'); + const stem = dot === -1 ? rel : rel.slice(0, dot); + const ext = dot === -1 ? '' : rel.slice(dot); + for (let attempt = 0; attempt < 100; attempt++) { + const candidate = attempt === 0 ? rel : `${stem}-${attempt + 1}${ext}`; + try { + const handle = await open(this.abs(candidate), 'wx'); + try { + await handle.writeFile(content, 'utf8'); + } finally { + await handle.close(); + } + return candidate; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue; + throw error; + } + } + throw new TowerProtocolError(`could not create a unique file for ${rel}`); + } +} + +/** Locate the real git dir (a worktree's `.git` is a file pointing elsewhere). */ +async function readGitDir(cwd: string): Promise { + try { + const raw = await readFile(join(cwd, '.git'), 'utf8'); + const match = /^gitdir:\s*(.+)$/m.exec(raw.trim()); + if (match?.[1] !== undefined) return match[1]; + return null; + } catch { + return null; + } +} diff --git a/packages/agent-core-v2/src/features/tower/protocol/types.ts b/packages/agent-core-v2/src/features/tower/protocol/types.ts new file mode 100644 index 0000000000..6fc5116aaa --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/protocol/types.ts @@ -0,0 +1,125 @@ +/** + * `tower` domain (protocol) — the machine-readable state types behind `.tower/`. + * + * `state.json` (this file's shapes) is the single source of truth; + * `MISSIONS.md` and `missions/*.md` are generated human views and must never + * be edited by hand. All writes go through `TowerStore`. + */ + +export type TowerAgentKind = 'worker' | 'reviewer'; + +export interface TowerRosterEntry { + /** Display/route name, e.g. `agent-build`, `reviewer-a`. Unique per workspace. */ + readonly name: string; + /** Engine agent id (e.g. `agent-3`); the tower is always `main`. */ + readonly agentId: string; + /** + * Session that spawned this agent. Engine agent ids are only unique within + * one session — after a CLI restart a new session reissues `agent-0`, … — so + * an entry is meaningful (resumable, dereferenceable) only in its own + * session. TowerInit retiring a foreign session's entries is what keeps + * id→name resolution unambiguous. + */ + readonly sessionId?: string; + readonly kind: TowerAgentKind; + /** Workers: the mission they own. */ + readonly missionId?: string; + /** Reviewers: the branch they are assigned to review. */ + readonly reviewTarget?: string; + /** Workers: worktree slot, e.g. `wt-1`. */ + readonly worktree?: string; + /** Workers: their branch, e.g. `feat/vulkan-build`. */ + readonly branch?: string; + readonly spawnedAt: string; +} + +export interface TowerRoster { + readonly agents: TowerRosterEntry[]; +} + +export type TowerMissionStatus = + | 'planned' + | 'active' + | 'completed' + | 'blocked' + | 'paused' + | 'merged'; + +/** + * `build` missions change code: their scope reserves write access (plan-time + * disjoint check, merge-time containment) and they merge through the full + * review gate. `survey` missions are read-only investigations: their scope is + * informational only (reserves nothing), and their merge is a zero-diff + * formality that closes the mission without a git merge. + */ +export type TowerMissionKind = 'build' | 'survey'; + +export interface TowerMissionTask { + text: string; + done: boolean; +} + +export interface TowerMission { + readonly id: string; + readonly title: string; + readonly slug: string; + kind: TowerMissionKind; + /** picomatch globs; mutable only through `updateMission` (tower, logged). */ + scope: string[]; + readonly branch: string; + readonly worktree: string; + readonly deps: readonly string[]; + status: TowerMissionStatus; + owner?: string; + tasks: TowerMissionTask[]; + /** Decision log, oldest first. */ + notes: string[]; + blockers: string[]; +} + +export interface TowerState { + readonly version: 1; + readonly base: string; + /** `pr` is reserved for a future gh-backed mode; v1 always runs `branch`. */ + readonly mode: 'branch' | 'pr'; + readonly createdAt: string; + /** + * Session that most recently ran TowerInit here. A different session + * re-initializing adopts the workspace: roster entries it did not spawn are + * retired (their engine agent ids are meaningless outside their own + * session), missions and worktrees are preserved. + */ + sessionId?: string; + roster: TowerRoster; + missions: TowerMission[]; +} + +export type TowerFindingType = 'bug' | 'improve' | 'vuln' | 'idea'; +export type TowerFindingSeverity = 'low' | 'medium' | 'high' | 'critical'; + +export type TowerReviewStatus = 'clean' | `p1-${number}items` | `p2-${number}items`; +export type TowerReviewMerge = 'merge' | 'fix-then-merge' | 'hold'; + +export interface TowerReviewInfo { + readonly reviewer: string; + readonly target: string; + readonly round: number; + readonly status: string; + readonly merge: string; + /** Branch tip the review was written against; merge gate compares it. */ + readonly reviewedCommit: string; + readonly date: string; + readonly file: string; +} + +export interface TowerInboxItem { + readonly file: string; + readonly from: string; + readonly to: string; + readonly subject: string; + readonly sentAt: string; + readonly scope?: string; + readonly action?: string; + readonly consentRef?: string; + readonly body: string; +} diff --git a/packages/agent-core-v2/src/features/tower/skill/skill.ts b/packages/agent-core-v2/src/features/tower/skill/skill.ts new file mode 100644 index 0000000000..56da4af44d --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/skill/skill.ts @@ -0,0 +1,33 @@ +/** + * `tower` domain — the builtin `tower` skill definition (the `/tower` slash + * command body). Self-registers into the builtin skill catalog at import via + * `registerBuiltinSkill` (the static import=register channel), so no builtin + * module needs to know the skill exists. + */ + +import type { SkillDefinition } from '#/app/skillCatalog/types'; +import { parseSkillText } from '#/app/skillCatalog/parser'; +import { registerBuiltinSkill } from '#/app/skillCatalog/builtin/registry'; +import TOWER_BODY from './tower.md?raw'; + +const PSEUDO_PATH = 'builtin://tower'; + +const parsed = parseSkillText({ + skillMdPath: '/builtin/skills/tower.md', + skillDirName: 'tower', + source: 'builtin', + text: TOWER_BODY, +}); + +export const TOWER_SKILL: SkillDefinition = { + ...parsed, + path: PSEUDO_PATH, + dir: PSEUDO_PATH, + metadata: { + ...parsed.metadata, + type: parsed.metadata.type ?? 'inline', + disableModelInvocation: true, + }, +}; + +registerBuiltinSkill(TOWER_SKILL); diff --git a/packages/agent-core-v2/src/features/tower/skill/tower.md b/packages/agent-core-v2/src/features/tower/skill/tower.md new file mode 100644 index 0000000000..ded86f088d --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/skill/tower.md @@ -0,0 +1,57 @@ +--- +name: tower +description: Orchestrate multiple agents iterating on one repo in parallel — you act as the unique control tower, spawn worker agents into their own git worktrees, and coordinate through code-enforced Tower tools (inbox/findings/reviews/merge gate/activity log). Use when the user runs /tower. +disable-model-invocation: true +--- + +# Tower mode (tower) + +Tower runs several agents on one repository at the same time without them stepping on each other. Three roles: + +- **The human** — owns the objective. May speak, launch, or redirect work **at any time**; nothing in this mode waits for the human. +- **The tower** — **you**, the main agent. Exactly one. You never write product code: you plan missions, spawn workers and reviewers, route information, merge branches, and keep the human informed. +- **Workers and reviewers** — subagents you spawn with `TowerSpawn`. Each worker owns one mission in its own git worktree; reviewers audit branches. + +**The protocol is enforced by tools, not by instructions.** All comms artifacts — inbox messages, findings, reviews, mission files, `MISSIONS.md`, the activity log — are produced by the `Tower*` tools. Workers and reviewers carry `TowerSend`, `TowerInbox`, `TowerFinding`, `TowerReview`, `TowerMission`, and `TowerStatus`; the tower additionally gets `TowerInit`, `TowerPlan`, `TowerSpawn`, `TowerMerge`, and `TowerTeardown`. File naming, frontmatter, recipient validity, review rounds, the merge gate, and the activity-log format are code. **Never create or edit files under `.tower/` by hand** (yours or via Bash): if a tool refuses, read the error — it tells you the correct next step. When something looks wrong, read `.tower/comms/log/activity.log` first; every action of every participant is there. + +Working principles: + +1. **Clarify up front. Never block on the human mid-run.** Use `AskUserQuestion` to pin down requirements with the human before you plan and spawn, while ambiguity is still cheap — that is the phase where asking beats deciding. Once the fleet is running, make the reasonable call yourself: record the decision (it lands in the activity log), inform the human in passing, proceed. The return channel is your normal chat reply (the human reads it when they come back) plus `activity.log` — say what you decided and why, in the open. Escalations are reported, not asked — unless every remaining thread is blocked, keep the others moving. Workers and reviewers cannot ask the human at all (their profile has no `AskUserQuestion`); they escalate to you with `TowerSend`. The single mid-run exception is creating git history over a non-empty directory (below): there, ask when asking is possible (not under auto permission mode) and take the safe default when it is not. +2. **Agents negotiate internally.** Workers talk to each other through `TowerSend` directly — questions, review requests, broadcasts (`to: "all"`). You are the coordinator and the only merger, not a content relay: you relay wake-ups (resume an idle agent with a pointer to what it should read), triage findings, untangle conflicts, and merge. +3. **Scope isolation is real.** `TowerPlan` rejects overlapping scopes, and `TowerMerge` refuses branches that changed files outside their mission scope. Plan scopes carefully; if a mission legitimately needs more, you widen it with `TowerMission` (scope patch — only you can, and it is logged). + +The user's input for this activation is: `$ARGUMENTS` + +- Empty or `status` → call `TowerStatus` and report a compact summary to the human. +- `teardown` → call `TowerTeardown` (it refuses to destroy dirty worktrees unless forced; report what it did). +- Anything else → the objective. If `TowerStatus` shows an initialized workspace, absorb it as new missions (`TowerPlan` appends; spawn more workers). Otherwise start at **Prepare** below. + +## Prepare (only when the directory is not a tower-ready git repo) + +`TowerInit` requires a git repository with at least one commit. If `git rev-parse --is-inside-work-tree` fails: + +- **Empty directory** → `git init` + `git commit --allow-empty -m "tower: init"`, then proceed. No confirmation needed. +- **Non-empty directory** → never `git add -A`: a blind initial commit can seal secrets, large binaries, or dependency directories into history irreversibly. Survey the directory (file count, largest files, secret-looking names like `.env` or `*.pem`), present the summary, and ask the human **exactly once** whether to initialize and commit the existing files — but only when asking is possible. Under auto permission mode `AskUserQuestion` is disabled: do not call it into a deny error. Default to the safe behavior instead — do NOT commit existing files; stop tower there and tell the human in your reply the two commands to run themselves (`git init` plus an initial commit of their choosing). If they agree to the commit, write a conservative `.gitignore` (dependencies, build output, secrets), show the staged list, commit, proceed. + +## Tower workflow + +1. **Init** — `TowerInit`. It creates `.tower/`, enables the tower tool set, and records the base branch. Workers and reviewers never prompt for tool approvals — they are pinned to the auto permission mode at spawn, whatever the session's mode. Your own orchestration calls still follow the session mode, so if it would interrupt you with constant prompts, tell the human once that a more autonomous mode fits tower better — then proceed regardless. +2. **Plan** — break the objective into 2–4 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate. +3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead. Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers bind the configured secondary model when the secondary-model experiment is on (they inherit your model otherwise); reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`. +4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act: + - Review request → `TowerSpawn` a reviewer (`kind: "reviewer"`, `review_target` the branch). Do not review mission code yourself. Survey missions skip review — close them with `TowerMerge` once their summary lands. + - Review verdict not clean → resume the author (Agent tool) pointing at the review file; the author fixes, pushes, and requests re-review. Round cap: at 5 rounds, or when two consecutive rounds report the same findings, stop the loop, inform the human, and redirect (reassign, split, descope). + - Blocker → answer or reassign if you can; if it genuinely needs the human, inform them and keep the rest moving. + - Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human. + - Completion report with a suspicious diff (🟢 claimed, zero changed files) → investigate before accepting. +5. **Merge** — `TowerMerge(branch)` in Dependency Flow order. The gate refuses when there is no clean review for the current tip, dependencies are unmerged, or files escaped the scope — the error message is your next step. After a merge, the result lists branches that now conflict: tell those workers (resume) to rebase onto the new base, resolve, push, and request re-review; their moved tip makes the gate demand a fresh clean review. +6. **Teardown promptly** — when `TowerStatus` shows every mission ✅ merged and no unactioned inbox items remain, call `TowerTeardown` **right away** and report the final summary (missions, merges, review rounds, findings and their disposition). Do not wait for the human to ask: branches and `.tower/comms/` (including the activity log) are kept and dirty worktrees are protected by the tool — only disk is freed. A `/tower teardown` from the human is the same instruction at any earlier point. + +## Hard rules for the tower + +- Exactly one tower. If a worker starts assigning work or merging, correct it on your next resume. +- Never write product code yourself; integration fixes at merge time are yours, everything else goes to a worker. +- Mission tracking lives in the tower protocol (`TowerPlan`/`TowerMission`/`TowerStatus`, `MISSIONS.md`), never in `TodoList` — it is code-denied in tower mode because todo semantics (one task in progress at a time) would serialize the fleet. +- Workers negotiate through `TowerSend`; you relay wake-ups and step in for conflicts, caps, findings, and merges. +- Never hand-edit `.tower/` files. The tools are the protocol. +- You perform every merge, through `TowerMerge` — never `git merge` by hand, never merge around a refusal. diff --git a/packages/agent-core-v2/src/features/tower/tools/finding/finding.md b/packages/agent-core-v2/src/features/tower/tools/finding/finding.md new file mode 100644 index 0000000000..e198a55220 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/finding/finding.md @@ -0,0 +1,3 @@ +File a structured finding (bug / improve / vuln / idea) into .tower/comms/findings/ for the tower to route. + +Use this for anything notable OUTSIDE your mission scope — fixing it directly would violate scope isolation. Include enough detail that another agent can act on it without re-discovering the context. diff --git a/packages/agent-core-v2/src/features/tower/tools/finding/finding.ts b/packages/agent-core-v2/src/features/tower/tools/finding/finding.ts new file mode 100644 index 0000000000..8a699bad91 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/finding/finding.ts @@ -0,0 +1,33 @@ +/** + * `tools` domain — `ITowerFindingTool` contract (the `TowerFinding` tool). + * + * Public contract of the structured finding filer (bug / improve / vuln / + * idea) for the tower to route; workers use it for anything notable outside + * their mission scope instead of fixing it directly. Exports the + * model-facing `TowerFindingToolInputSchema` / `TowerFindingToolInput` and + * the `ITowerFindingTool` DI decorator. Bound at Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerFindingToolInputSchema = z + .object({ + type: z.enum(['bug', 'improve', 'vuln', 'idea']).describe('Finding category'), + title: z.string().describe('Short finding title'), + severity: z.enum(['low', 'medium', 'high', 'critical']).optional(), + summary: z.string().describe('What was found, in a sentence or two'), + location: z.string().optional().describe('File/symbol the finding concerns'), + details: z.string().describe('Full details: evidence, reproduction, impact'), + suggested_fix: z.string().describe('What you would do about it'), + }) + .strict(); + +export type TowerFindingToolInput = z.infer; + +export interface ITowerFindingTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerFindingTool = createDecorator('towerFindingTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts b/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts new file mode 100644 index 0000000000..0e9dd287c7 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts @@ -0,0 +1,60 @@ +/** + * `tools` domain — `TowerFindingTool` implementation (the `TowerFinding` + * tool). + * + * Files the finding through the protocol `TowerStore` rooted at the session + * cwd (`sessionContext`), resolving the caller's roster identity from the + * agent scope (`scopeContext`). Registered for every agent — visibility is + * controlled by profile tool lists. Bound at Agent scope. + */ + +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { callerName, newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './finding.md?raw'; +import { + ITowerFindingTool, + TowerFindingToolInputSchema, + type TowerFindingToolInput, +} from './finding'; + +export class TowerFindingTool implements ITowerFindingTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerFinding' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerFindingToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) {} + + resolveExecution(args: TowerFindingToolInput): ToolExecution { + return { + description: `Filing tower ${args.type} finding: ${args.title}`, + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const state = await store.load(); + const caller = callerName(this.scopeContext.agentId, store, state); + const rel = await store.fileFinding(caller, { + type: args.type, + title: args.title, + severity: args.severity, + summary: args.summary, + location: args.location, + details: args.details, + suggestedFix: args.suggested_fix, + }); + return { + output: `finding filed: ${rel}\nThe tower will route it — do not fix out-of-scope issues yourself.`, + }; + }), + }; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.md b/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.md new file mode 100644 index 0000000000..71c4e7b985 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.md @@ -0,0 +1 @@ +Read your tower inbox: messages addressed to you plus broadcasts, newest first. The tower sees all messages. Full bodies are included — reply with TowerSend. diff --git a/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.ts b/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.ts new file mode 100644 index 0000000000..3b25ebf6cc --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/inbox/inbox.ts @@ -0,0 +1,31 @@ +/** + * `tools` domain — `ITowerInboxTool` contract (the `TowerInbox` tool). + * + * Public contract of the tower inbox reader: messages addressed to the + * caller (or broadcast), newest first; the tower sees every message. Exports + * the model-facing `TowerInboxToolInputSchema` / `TowerInboxToolInput` and + * the `ITowerInboxTool` DI decorator. Bound at Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerInboxToolInputSchema = z + .object({ + limit: z + .number() + .int() + .positive() + .optional() + .describe('Max messages to return (default 20), newest first'), + }) + .strict(); + +export type TowerInboxToolInput = z.infer; + +export interface ITowerInboxTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerInboxTool = createDecorator('towerInboxTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/inbox/inboxTool.ts b/packages/agent-core-v2/src/features/tower/tools/inbox/inboxTool.ts new file mode 100644 index 0000000000..3dac5be111 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/inbox/inboxTool.ts @@ -0,0 +1,69 @@ +/** + * `tools` domain — `TowerInboxTool` implementation (the `TowerInbox` tool). + * + * Reads the inbox through the protocol `TowerStore` rooted at the session + * cwd (`sessionContext`), resolving the caller's roster identity from the + * agent scope (`scopeContext`). Registered for every agent — visibility is + * controlled by profile tool lists. Bound at Agent scope. + */ + +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { callerName, newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './inbox.md?raw'; +import { ITowerInboxTool, TowerInboxToolInputSchema, type TowerInboxToolInput } from './inbox'; + +const DEFAULT_LIMIT = 20; + +export class TowerInboxTool implements ITowerInboxTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerInbox' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerInboxToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) {} + + resolveExecution(args: TowerInboxToolInput): ToolExecution { + return { + description: 'Reading tower inbox', + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const state = await store.load(); + const caller = callerName(this.scopeContext.agentId, store, state); + const items = await store.readInbox(caller, args.limit ?? DEFAULT_LIMIT); + if (items.length === 0) { + return { output: `inbox empty for ${caller}` }; + } + const sections = items.map((item) => + [ + `file: ${item.file}`, + `from: ${item.from}`, + `to: ${item.to}`, + `subject: ${item.subject}`, + `sent_at: ${item.sentAt}`, + ...(item.scope !== undefined ? [`scope: ${item.scope}`] : []), + ...(item.action !== undefined ? [`action: ${item.action}`] : []), + '', + item.body, + ].join('\n'), + ); + return { + output: [ + `${String(items.length)} message(s) for ${caller} (newest first):`, + '', + sections.join('\n\n---\n\n'), + ].join('\n'), + }; + }), + }; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/init/init.md b/packages/agent-core-v2/src/features/tower/tools/init/init.md new file mode 100644 index 0000000000..66387596c4 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/init/init.md @@ -0,0 +1,5 @@ +Initialize a tower multi-agent workspace in the current repository. + +Creates the .tower/ directory (comms state, inbox, findings, reviews, missions, activity log, worktree slots), enters tower mode, and activates the full tower tool set (TowerPlan/TowerSpawn/TowerMerge/TowerTeardown plus the shared TowerSend/TowerInbox/TowerFinding/TowerReview/TowerMission/TowerStatus). + +Use this when a task is large enough to split across multiple parallel agents with isolated git worktrees and a review-gated merge protocol. Safe to call again — an existing workspace is reported, never reset. Re-entering from a new CLI session adopts the workspace: roster entries the previous session spawned are retired (their agent ids cannot be resumed across sessions), while missions, worktrees, and the activity log carry over. diff --git a/packages/agent-core-v2/src/features/tower/tools/init/init.ts b/packages/agent-core-v2/src/features/tower/tools/init/init.ts new file mode 100644 index 0000000000..93befaabe0 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/init/init.ts @@ -0,0 +1,23 @@ +/** + * `tools` domain — `ITowerInitTool` contract (the `TowerInit` tool). + * + * Public contract of the tower workspace initializer: creates the `.tower/` + * workspace, enters tower mode, and activates the rest of the tower tool + * set. Exports the model-facing `TowerInitToolInputSchema` / + * `TowerInitToolInput` and the `ITowerInitTool` DI decorator. Bound at Agent + * scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerInitToolInputSchema = z.object({}).strict(); + +export type TowerInitToolInput = z.infer; + +export interface ITowerInitTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerInitTool = createDecorator('towerInitTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/init/initTool.ts b/packages/agent-core-v2/src/features/tower/tools/init/initTool.ts new file mode 100644 index 0000000000..df2813dce1 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/init/initTool.ts @@ -0,0 +1,70 @@ +/** + * `tools` domain — `TowerInitTool` implementation (the `TowerInit` tool). + * + * Creates the `.tower/` workspace through the protocol `TowerStore` rooted at + * the session cwd (`sessionContext`), enters tower mode via `tower`, and + * activates the tower tool set through `profile`. Idempotent within a + * session: re-running against an existing workspace reports `created: false` + * and keeps all state (e.g. after a session resume). Re-running from a + * *different* session adopts the workspace: roster entries the previous + * session spawned are retired (their engine agent ids are session-scoped and + * cannot be resumed here), missions and worktrees are preserved, and the + * output tells the tower which names were retired so it can respawn. + * Registered for the main agent only. Bound at Agent scope. + */ + +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentTowerService, TOWER_TOOL_NAMES } from '#/features/tower/tower'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './init.md?raw'; +import { ITowerInitTool, TowerInitToolInputSchema, type TowerInitToolInput } from './init'; + +export class TowerInitTool implements ITowerInitTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerInit' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerInitToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentTowerService private readonly tower: IAgentTowerService, + @IAgentProfileService private readonly profile: IAgentProfileService, + ) {} + + resolveExecution(_args: TowerInitToolInput): ToolExecution { + return { + description: 'Initializing tower workspace', + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const result = await store.init(this.sessionContext.sessionId); + this.tower.enter(); + for (const name of TOWER_TOOL_NAMES) this.profile.addActiveTool(name); + return { + output: [ + result.created + ? 'tower workspace initialized' + : 'tower workspace already initialized — existing state preserved', + `base branch: ${result.base}`, + 'workspace: .tower/ (comms under .tower/comms/, worktrees under .tower/worktrees/)', + ...(result.retiredAgents.length > 0 + ? [ + `adopted from a previous session — retired its stale roster entries: ${result.retiredAgents.join(', ')}. ` + + 'Their agents belong to the dead session and cannot be resumed; missions and worktrees are preserved — TowerSpawn fresh workers to continue them.', + ] + : []), + '', + 'Tower mode is active and the tower tool set is enabled.', + 'Next: split the work with TowerPlan (one mission per disjoint file scope), then TowerSpawn a worker per mission. Assign reviewers for their branches, and merge with TowerMerge only after a clean review.', + ].join('\n'), + }; + }), + }; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/merge.md b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md new file mode 100644 index 0000000000..afaeaab406 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/merge/merge.md @@ -0,0 +1,3 @@ +Merge a tower mission branch into the base branch (--no-ff). + +Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge. diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/merge.ts b/packages/agent-core-v2/src/features/tower/tools/merge/merge.ts new file mode 100644 index 0000000000..efb82f880a --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/merge/merge.ts @@ -0,0 +1,30 @@ +/** + * `tools` domain — `ITowerMergeTool` contract (the `TowerMerge` tool). + * + * Public contract of the tower's merge lever: the store is the hard gate — + * it refuses when the branch has no review, the latest review is not clean, + * the branch tip moved since the clean review, dependencies are unmerged, or + * the branch changed files outside its mission scope. Exports the + * model-facing `TowerMergeToolInputSchema` / `TowerMergeToolInput` and the + * `ITowerMergeTool` DI decorator. Bound at Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerMergeToolInputSchema = z + .object({ + branch: z + .string() + .describe('The mission branch to merge into the base branch (e.g. "feat/vulkan-build")'), + }) + .strict(); + +export type TowerMergeToolInput = z.infer; + +export interface ITowerMergeTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerMergeTool = createDecorator('towerMergeTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts b/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts new file mode 100644 index 0000000000..d1eb4be6c3 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/merge/mergeTool.ts @@ -0,0 +1,64 @@ +/** + * `tools` domain — `TowerMergeTool` implementation (the `TowerMerge` tool). + * + * Merges a mission branch through the protocol `TowerStore` rooted at the + * session cwd (`sessionContext`); after a successful merge it reports which + * unmerged branches touched the same files (they must rebase — their moved + * tip then fails the reviewed_commit gate, forcing a re-review). Registered + * for the main agent only. Bound at Agent scope. + */ + +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './merge.md?raw'; +import { ITowerMergeTool, TowerMergeToolInputSchema, type TowerMergeToolInput } from './merge'; + +export class TowerMergeTool implements ITowerMergeTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerMerge' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerMergeToolInputSchema); + + constructor(@ISessionContext private readonly sessionContext: ISessionContext) {} + + resolveExecution(args: TowerMergeToolInput): ToolExecution { + return { + description: `Merging tower branch: ${args.branch}`, + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const { mergeCommit, conflictsWith, noop } = await store.merge(args.branch); + if (noop === true) { + return { + output: [ + `${args.branch} is a read-only survey with a zero-diff branch — mission marked merged, no git merge needed.`, + 'Continue with the remaining missions in Dependency Flow order.', + ].join('\n'), + }; + } + const lines = [ + `merged ${args.branch} (merge commit ${mergeCommit.slice(0, 7)})`, + `full commit: ${mergeCommit}`, + ]; + if (conflictsWith.length > 0) { + lines.push( + '', + 'These unmerged branches changed the same files and now likely conflict with the base:', + ...conflictsWith.map( + (conflict) => `- ${conflict.branch}: ${conflict.files.join(', ')}`, + ), + 'Tell each affected worker (Agent resume) to rebase onto the updated base, resolve, push, and request a re-review.', + ); + } else { + lines.push('The mission is now marked merged. Continue with the remaining missions in Dependency Flow order.'); + } + return { output: lines.join('\n') }; + }), + }; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/mission/mission.md b/packages/agent-core-v2/src/features/tower/tools/mission/mission.md new file mode 100644 index 0000000000..a03d383b96 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/mission/mission.md @@ -0,0 +1,3 @@ +Read or update a tower mission. + +With only an id, returns the mission view (status, tasks, blockers, notes). With patch fields, applies them: workers may only update the mission they own — the store rejects anything else. Use task_done to tick checklist items, note to log decisions, blocker when stuck (the tower watches for blocked missions). diff --git a/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts b/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts new file mode 100644 index 0000000000..b037399d99 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/mission/mission.ts @@ -0,0 +1,45 @@ +/** + * `tools` domain — `ITowerMissionTool` contract (the `TowerMission` tool). + * + * Public contract of the mission reader/patcher: called with only an id it + * returns the rendered mission view; with patch fields it applies them + * through the store (workers may only patch their own mission; ownership + * assignment stays with the tower). Exports the model-facing + * `TowerMissionToolInputSchema` / `TowerMissionToolInput` and the + * `ITowerMissionTool` DI decorator. Bound at Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerMissionToolInputSchema = z + .object({ + id: z.string().describe('Mission id (e.g. "M1")'), + status: z + .enum(['planned', 'active', 'completed', 'blocked', 'paused', 'merged']) + .optional() + .describe('New lifecycle status'), + note: z.string().optional().describe('Append a decision-log note'), + blocker: z.string().optional().describe('Report a blocker (also sets status to blocked)'), + clear_blockers: z.boolean().optional().describe('Clear all recorded blockers'), + task_done: z + .string() + .optional() + .describe('Mark the first open task containing this text as done'), + scope: z + .array(z.string()) + .optional() + .describe( + 'Tower only: replace the mission scope globs (picomatch — `**` crosses directories). Logged; widens what the merge gate accepts.', + ), + }) + .strict(); + +export type TowerMissionToolInput = z.infer; + +export interface ITowerMissionTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerMissionTool = createDecorator('towerMissionTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/mission/missionTool.ts b/packages/agent-core-v2/src/features/tower/tools/mission/missionTool.ts new file mode 100644 index 0000000000..b1a38ffa35 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/mission/missionTool.ts @@ -0,0 +1,95 @@ +/** + * `tools` domain — `TowerMissionTool` implementation (the `TowerMission` + * tool). + * + * Reads and patches missions through the protocol `TowerStore` rooted at the + * session cwd (`sessionContext`), resolving the caller's roster identity + * from the agent scope (`scopeContext`). Registered for every agent — + * visibility is controlled by profile tool lists. Bound at Agent scope. + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { MISSIONS_DIR, missionFileName } from '#/features/tower/protocol/index'; +import type { TowerMission, TowerStore } from '#/features/tower/protocol/index'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { callerName, newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './mission.md?raw'; +import { + ITowerMissionTool, + TowerMissionToolInputSchema, + type TowerMissionToolInput, +} from './mission'; + +export class TowerMissionTool implements ITowerMissionTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerMission' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerMissionToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) {} + + resolveExecution(args: TowerMissionToolInput): ToolExecution { + const hasPatch = + args.status !== undefined || + args.note !== undefined || + args.blocker !== undefined || + args.clear_blockers !== undefined || + args.task_done !== undefined || + args.scope !== undefined; + return { + description: hasPatch + ? `Updating tower mission ${args.id}` + : `Reading tower mission ${args.id}`, + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const state = await store.load(); + const caller = callerName(this.scopeContext.agentId, store, state); + if (!hasPatch) { + const mission = state.missions.find((m) => m.id === args.id); + if (mission === undefined) { + const known = state.missions.map((m) => m.id).join(', '); + return { + output: `unknown mission "${args.id}" — known missions: ${known.length > 0 ? known : '(none planned yet)'}`, + isError: true, + }; + } + return { output: await renderMission(store, mission) }; + } + const mission = await store.updateMission(caller, args.id, { + status: args.status, + note: args.note, + blocker: args.blocker, + clearBlockers: args.clear_blockers, + taskDone: args.task_done, + scope: args.scope, + }); + return { + output: [ + `mission ${mission.id} updated — status: ${mission.status}, open tasks: ${String(mission.tasks.filter((t) => !t.done).length)}, blockers: ${String(mission.blockers.length)}`, + '', + await renderMission(store, mission), + ].join('\n'), + }; + }), + }; + } +} + +async function renderMission(store: TowerStore, mission: TowerMission): Promise { + return readFile( + store.abs(join(MISSIONS_DIR, missionFileName(mission.id, mission.slug))), + 'utf8', + ); +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/plan/plan.md b/packages/agent-core-v2/src/features/tower/tools/plan/plan.md new file mode 100644 index 0000000000..8b837abf42 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/plan/plan.md @@ -0,0 +1,3 @@ +Split the tower goal into missions. Each mission gets an id (M1, M2, …), a branch (feat/), and an isolated git worktree (.tower/worktrees/wt-N). + +Rules enforced by the store: scopes of build missions must be pairwise disjoint (survey missions are read-only and reserve no scope), and deps must reference existing mission ids. Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first). diff --git a/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts b/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts new file mode 100644 index 0000000000..fd6210fe0f --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/plan/plan.ts @@ -0,0 +1,55 @@ +/** + * `tools` domain — `ITowerPlanTool` contract (the `TowerPlan` tool). + * + * Public contract of the tower's mission splitter: each mission gets an id, + * a branch, and a worktree slot; scopes must be pairwise disjoint and deps + * must reference known mission ids (both enforced by the store). Exports the + * model-facing `TowerPlanToolInputSchema` / `TowerPlanToolInput` and the + * `ITowerPlanTool` DI decorator. Bound at Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerPlanToolInputSchema = z + .object({ + missions: z + .array( + z + .object({ + title: z.string().describe('Short mission title; becomes the branch/worktree slug'), + scope: z + .array(z.string()) + .min(1) + .describe( + 'Files/globs this mission may touch (e.g. "src/build/**"). Scopes of different missions must not overlap.', + ), + tasks: z + .array(z.string()) + .optional() + .describe('Checklist the worker will tick off via TowerMission task_done'), + deps: z + .array(z.string()) + .optional() + .describe('Mission ids (e.g. "M1") that must merge before this one can merge'), + kind: z + .enum(['build', 'survey']) + .optional() + .describe( + '"survey" = read-only investigation: the scope is informational and reserves nothing (other missions may overlap it), the worker must not change code, and closing it needs no review or git merge. Default "build".', + ), + }) + .strict(), + ) + .min(1), + }) + .strict(); + +export type TowerPlanToolInput = z.infer; + +export interface ITowerPlanTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerPlanTool = createDecorator('towerPlanTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/plan/planTool.ts b/packages/agent-core-v2/src/features/tower/tools/plan/planTool.ts new file mode 100644 index 0000000000..befd00a5a4 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/plan/planTool.ts @@ -0,0 +1,63 @@ +/** + * `tools` domain — `TowerPlanTool` implementation (the `TowerPlan` tool). + * + * Splits the tower goal into missions through the protocol `TowerStore` + * rooted at the session cwd (`sessionContext`), refusing to run while tower + * mode (`tower`) is inactive. Registered for the main agent only. Bound at + * Agent scope. + */ + +import { IAgentTowerService } from '#/features/tower/tower'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './plan.md?raw'; +import { ITowerPlanTool, TowerPlanToolInputSchema, type TowerPlanToolInput } from './plan'; + +export class TowerPlanTool implements ITowerPlanTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerPlan' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerPlanToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentTowerService private readonly tower: IAgentTowerService, + ) {} + + resolveExecution(args: TowerPlanToolInput): ToolExecution { + return { + description: `Planning ${String(args.missions.length)} tower mission(s)`, + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + if (!this.tower.isActive) { + return { + output: 'tower mode is not active — run TowerInit first', + isError: true, + }; + } + const store = newTowerStore(this.sessionContext); + const missions = await store.plan(args.missions); + const rows = missions.map( + (m) => + `| ${m.id} | ${m.title} | ${m.kind} | ${m.branch} | ${m.worktree} | ${m.scope.join(', ')} |`, + ); + return { + output: [ + `planned ${String(missions.length)} mission(s):`, + '', + '| ID | Mission | Kind | Branch | Worktree | Scope |', + '| -- | ------- | ---- | ------ | -------- | ----- |', + ...rows, + '', + 'Next: TowerSpawn one worker per mission (workers get their worktree path and mission briefing automatically), plus reviewers for the branches. Survey missions need no reviewer — they close with a zero-diff TowerMerge.', + ].join('\n'), + }; + }), + }; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/review/review.md b/packages/agent-core-v2/src/features/tower/tools/review/review.md new file mode 100644 index 0000000000..35cf7c0be3 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/review/review.md @@ -0,0 +1,3 @@ +Submit a review verdict for a branch you were assigned to review (via TowerSpawn review_target). + +The review is stamped with the current branch tip — if the branch moves afterwards, the tower must ask for a re-review before merging. Only reviewers assigned to the target (or the tower) may submit; the round number is assigned automatically. diff --git a/packages/agent-core-v2/src/features/tower/tools/review/review.ts b/packages/agent-core-v2/src/features/tower/tools/review/review.ts new file mode 100644 index 0000000000..b67d194951 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/review/review.ts @@ -0,0 +1,42 @@ +/** + * `tools` domain — `ITowerReviewTool` contract (the `TowerReview` tool). + * + * Public contract of the reviewer's branch verdict: the store assigns the + * round number, stamps the reviewed branch tip, and enforces that the caller + * is an assigned reviewer for the target. Exports the model-facing + * `TowerReviewToolInputSchema` / `TowerReviewToolInput` and the + * `ITowerReviewTool` DI decorator. Bound at Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerReviewToolInputSchema = z + .object({ + target: z.string().describe('The branch you were assigned to review'), + status: z + .string() + .regex(/^(clean|p[12]-\d+items)$/) + .describe( + 'Verdict: "clean", or "p1-Nitems" / "p2-Nitems" with the number of findings at that priority', + ), + merge: z + .enum(['merge', 'fix-then-merge', 'hold']) + .describe('Merge recommendation for the tower'), + findings: z.string().describe('Full findings text (markdown); write "none" when clean'), + checks: z + .array(z.string()) + .optional() + .describe('Checklist items you verified (e.g. "tests pass", "no secrets")'), + decision: z.string().describe('The reasoning behind your verdict'), + }) + .strict(); + +export type TowerReviewToolInput = z.infer; + +export interface ITowerReviewTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerReviewTool = createDecorator('towerReviewTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts new file mode 100644 index 0000000000..1e651eda53 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts @@ -0,0 +1,60 @@ +/** + * `tools` domain — `TowerReviewTool` implementation (the `TowerReview` + * tool). + * + * Submits the verdict through the protocol `TowerStore` rooted at the + * session cwd (`sessionContext`), resolving the caller's roster identity + * from the agent scope (`scopeContext`); only a "clean" review of the exact + * current tip passes the merge gate. Registered for every agent — visibility + * is controlled by profile tool lists. Bound at Agent scope. + */ + +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { callerName, newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './review.md?raw'; +import { + ITowerReviewTool, + TowerReviewToolInputSchema, + type TowerReviewToolInput, +} from './review'; + +export class TowerReviewTool implements ITowerReviewTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerReview' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerReviewToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) {} + + resolveExecution(args: TowerReviewToolInput): ToolExecution { + return { + description: `Submitting tower review for ${args.target}: ${args.status}`, + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const state = await store.load(); + const caller = callerName(this.scopeContext.agentId, store, state); + const rel = await store.submitReview(caller, { + target: args.target, + status: args.status, + merge: args.merge, + findings: args.findings, + checks: args.checks, + decision: args.decision, + }); + return { + output: `review submitted: ${rel}\nAlso notify the branch author (or the tower) with TowerSend so the verdict is seen.`, + }; + }), + }; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/send/send.md b/packages/agent-core-v2/src/features/tower/tools/send/send.md new file mode 100644 index 0000000000..d6ff907239 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/send/send.md @@ -0,0 +1,3 @@ +Send an inbox message to a tower participant: a roster agent by name, "tower" (the control tower), or "all" (broadcast). + +Recipients read it with TowerInbox. Sending to yourself or to an unknown name is rejected — the error lists the known names. diff --git a/packages/agent-core-v2/src/features/tower/tools/send/send.ts b/packages/agent-core-v2/src/features/tower/tools/send/send.ts new file mode 100644 index 0000000000..c2972e890d --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/send/send.ts @@ -0,0 +1,37 @@ +/** + * `tools` domain — `ITowerSendTool` contract (the `TowerSend` tool). + * + * Public contract of the tower inbox sender: delivers a message to a roster + * agent, the tower, or everyone ("all"); the store builds the file name and + * frontmatter. Exports the model-facing `TowerSendToolInputSchema` / + * `TowerSendToolInput` and the `ITowerSendTool` DI decorator. Bound at Agent + * scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerSendToolInputSchema = z + .object({ + to: z + .string() + .describe('Recipient: a roster agent name, "tower", or "all" (broadcast)'), + subject: z.string().describe('One-line subject; keep it greppable'), + body: z.string().describe('Full message body (markdown)'), + scope: z.string().optional().describe('Optional scope tag (e.g. the mission id)'), + action: z.string().optional().describe('Optional action tag for machine routing'), + consent_ref: z + .string() + .optional() + .describe('Optional reference to a consent/approval record this message relies on'), + }) + .strict(); + +export type TowerSendToolInput = z.infer; + +export interface ITowerSendTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerSendTool = createDecorator('towerSendTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts new file mode 100644 index 0000000000..a537430220 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts @@ -0,0 +1,52 @@ +/** + * `tools` domain — `TowerSendTool` implementation (the `TowerSend` tool). + * + * Delivers the message through the protocol `TowerStore` rooted at the + * session cwd (`sessionContext`), resolving the caller's roster identity + * from the agent scope (`scopeContext`). Registered for every agent — + * visibility is controlled by profile tool lists. Bound at Agent scope. + */ + +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { callerName, newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './send.md?raw'; +import { ITowerSendTool, TowerSendToolInputSchema, type TowerSendToolInput } from './send'; + +export class TowerSendTool implements ITowerSendTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerSend' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerSendToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + ) {} + + resolveExecution(args: TowerSendToolInput): ToolExecution { + return { + description: `Sending tower message to ${args.to}: ${args.subject}`, + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const state = await store.load(); + const caller = callerName(this.scopeContext.agentId, store, state); + const rel = await store.send(caller, { + to: args.to, + subject: args.subject, + body: args.body, + scope: args.scope, + action: args.action, + consentRef: args.consent_ref, + }); + return { output: `message sent to ${args.to}\nfile: ${rel}` }; + }), + }; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md new file mode 100644 index 0000000000..425d2aed01 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.md @@ -0,0 +1,5 @@ +Spawn a tower worker or reviewer as a background subagent and register it in the tower roster. + +Workers: pass mission_id — the tool creates the mission worktree, marks the mission active with this worker as owner, and briefs the agent with the full mission text. Reviewers: pass review_target — the agent gets a review checklist and must submit its verdict via TowerReview. + +The briefing prompt is assembled by this tool (worktree path, scope, protocol rules); use instructions only for extra context. If the name is already registered, resume the existing agent with the Agent tool instead of spawning a duplicate. diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.ts new file mode 100644 index 0000000000..8e40e2293c --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawn.ts @@ -0,0 +1,61 @@ +/** + * `tools` domain — `ITowerSpawnTool` contract (the `TowerSpawn` tool). + * + * Public contract of the tower's worker/reviewer launcher: the input schema + * (verbatim port of v1 — worker spawns require `mission_id`, reviewer spawns + * require `review_target`) and the `ITowerSpawnTool` DI decorator. Bound at + * Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerSpawnToolInputSchema = z + .object({ + name: z + .string() + .describe( + 'Unique tower name for the agent (e.g. "agent-build", "reviewer-a"). Used for inbox addressing and mission ownership.', + ), + kind: z + .enum(['worker', 'reviewer']) + .describe('workers execute a mission in their worktree; reviewers review one branch'), + mission_id: z + .string() + .optional() + .describe('Required for workers: the mission id (e.g. "M1") from TowerPlan'), + review_target: z + .string() + .optional() + .describe('Required for reviewers: the branch to review (e.g. "feat/vulkan-build")'), + instructions: z + .string() + .optional() + .describe('Extra tower instructions appended to the generated briefing'), + }) + .strict() + .superRefine((value, ctx) => { + if (value.kind === 'worker' && (value.mission_id ?? '').trim().length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['mission_id'], + message: 'worker spawns require mission_id', + }); + } + if (value.kind === 'reviewer' && (value.review_target ?? '').trim().length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['review_target'], + message: 'reviewer spawns require review_target', + }); + } + }); + +export type TowerSpawnToolInput = z.infer; + +export interface ITowerSpawnTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerSpawnTool = createDecorator('towerSpawnTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts new file mode 100644 index 0000000000..7114ea62e4 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -0,0 +1,464 @@ +/** + * `tools` domain — `TowerSpawnTool` implementation (the `TowerSpawn` tool). + * + * The tower's worker/reviewer launcher, ported from v1 + * (`agent-core/src/tools/builtin/tower/spawn.ts`) onto v2's spawn composition: + * the briefing prompt is assembled here from the mission/review briefing + * (never by the tower LLM), the roster/worktree/mission bookkeeping goes + * through `TowerStore` (`tower` domain protocol), the agent is created + * through `IAgentLifecycleService` with the `tower-worker` profile and driven + * via `ISessionSubagentService.run` mirrored onto the tower's record stream + * (`mirrorAgentRun`), and the run is registered detached with + * `IAgentTaskService` under a `SubagentTask` — the same background path as + * the `Agent` tool. Spawn concurrency is gated by `ITowerRateLimitService`; + * the slot is released when the agent's completion settles (or immediately on + * a launch/registration failure). The worker's model binding follows the same + * rule as the `Agent`/`AgentSwarm` tools (`resolveSubagentBinding`): the + * configured secondary model while the secondary-model experiment is on, + * otherwise the tower's own model — except reviewers, who always bind the + * tower's (primary) model. The resolved model is reported in the tool output + * and the `spawn` line of the tower activity log. The spawned agent is pinned + * to the `auto` permission mode regardless of the tower's own mode: workers + * and reviewers run detached and unattended, so per-call approval prompts + * would serialize the fleet on the user's attention (and with no approval + * broker attached they silently auto-approve anyway). User-configured deny + * rules and the tower-worker write guard still apply — both adjudicate + * independently of the mode. `broadcastPermissionMode` skips + * `tower-worker`-profile agents, so a later session-wide mode switch does not + * move them off `auto` either. + * + * Deliberate v2 adaptation — no per-agent cwd: v1 confined each worker by + * overriding its process cwd to the worktree. v2 freezes the session cwd by + * design (every agent shares it), so confinement is instead (a) briefed — + * `buildPrompt` states the worker's absolute worktree path and instructs it + * to address every file operation (and every Bash `cd`) at that path — and + * (b) enforced — the tower-worker write guard in `towerService.ts` hard-denies + * Write/Edit outside the roster-recorded worktree. `buildPrompt` is otherwise + * a verbatim v1 port; only the cwd-relative phrasing changed. + * + * Registered main-only (the tower) by `towerFeature.ts`. Bound at Agent + * scope. + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { IAgentTaskService } from '#/agent/task/task'; +import { + GitError, + MISSIONS_DIR, + TOWER_NAME, + TowerProtocolError, + TowerStore, + WORKTREES_DIR, + missionFileName, + resolveTowerRepoRoot, + type TowerMission, + type TowerState, +} from '#/features/tower/protocol/index'; +import { IAgentTowerService, TOWER_WORKER_PROFILE } from '#/features/tower/tower'; +import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { IModelCatalog } from '#/kosong/model/catalog'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { + type ExecutableToolContext, + type ExecutableToolResult, + type ToolExecution, +} from '#/tool/toolContract'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { subagentLabels } from '#/session/agentLifecycle/subagentMetadata'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { + DEFAULT_SUBAGENT_TIMEOUT_MS, + resolveSubagentBinding, + wrapSubagentModelError, +} from '#/session/subagent/configSection'; +import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; +import { ISessionSubagentService } from '#/session/subagent/subagent'; + +import { SubagentTask, type SubagentHandle } from '#/agent/tools/agent/subagent-task'; + +import { ITowerSpawnTool, TowerSpawnToolInputSchema, type TowerSpawnToolInput } from './spawn'; +import DESCRIPTION from './spawn.md?raw'; + +type SubagentBinding = ReturnType; + +export class TowerSpawnTool implements ITowerSpawnTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerSpawn' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerSpawnToolInputSchema); + + private readonly callerAgentId: string; + + constructor( + @IAgentTowerService private readonly tower: IAgentTowerService, + @ITowerRateLimitService private readonly rateLimit: ITowerRateLimitService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext scopeContext: IAgentScopeContext, + @IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService, + @ISessionSubagentService private readonly subagents: ISessionSubagentService, + @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + ) { + this.callerAgentId = scopeContext.agentId; + } + + resolveExecution(args: TowerSpawnToolInput): ToolExecution { + return { + description: `Spawning tower ${args.kind} "${args.name}"`, + approvalRule: this.name, + execute: (ctx) => this.execution(args, ctx), + }; + } + + private newStore(): TowerStore { + return new TowerStore(resolveTowerRepoRoot(this.sessionContext.cwd)); + } + + private async execution( + args: TowerSpawnToolInput, + { toolCallId }: ExecutableToolContext, + ): Promise { + try { + if (!this.tower.isActive) { + return { + output: 'tower mode is not active — run TowerInit first', + isError: true, + }; + } + const store = this.newStore(); + const state = await store.load(); + + const existing = store.findByName(state, args.name); + if (existing !== undefined) { + return { + output: + `tower agent "${args.name}" is already registered (agent_id: ${existing.agentId}, kind: ${existing.kind}) — ` + + `resume it instead of spawning a duplicate: Agent(resume="${existing.agentId}", prompt="...")`, + isError: true, + }; + } + + const notes: string[] = []; + let mission: TowerMission | undefined; + let reviewTarget: string | undefined; + if (args.kind === 'worker') { + const missionId = args.mission_id; + if (missionId === undefined) { + return { output: 'worker spawns require mission_id', isError: true }; + } + mission = state.missions.find((m) => m.id === missionId); + if (mission === undefined) { + const known = state.missions.map((m) => m.id).join(', '); + return { + output: `unknown mission "${missionId}" — known missions: ${known.length > 0 ? known : '(none planned yet)'}`, + isError: true, + }; + } + try { + await store.addWorktree(mission.worktree, mission.branch, state.base); + } catch (error) { + // The worktree may already exist (e.g. respawn after a crash) — the + // agent can still work in it; surface the git message and continue. + notes.push( + `worktree setup warning (continuing): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + reviewTarget = args.review_target; + if (reviewTarget === undefined) { + return { output: 'reviewer spawns require review_target', isError: true }; + } + } + + const prompt = await this.buildPrompt(args, store, state, mission, reviewTarget); + const description = + mission !== undefined + ? `tower worker ${args.name}: ${mission.title}` + : `tower reviewer ${args.name}: ${reviewTarget ?? ''}`; + + // Adaptive concurrency gate: refused while the provider is rate-limiting + // (pause) or the inflight budget is exhausted. The slot is released when + // the agent's completion settles — or immediately on a launch failure. + const gate = this.rateLimit.acquire(); + if (!gate.ok) { + return { output: gate.reason, isError: true }; + } + let slotHeld = true; + try { + const controller = new AbortController(); + // The same binding rule as the Agent/AgentSwarm tools: the configured + // secondary model when the experiment is on, otherwise inherit the + // tower's own model. Reviewers are the exception — they always bind + // the tower's (primary) model: review quality is not where the + // secondary model saves money. + const own = this.profile.data(); + const binding = + own.modelAlias === undefined + ? undefined + : resolveSubagentBinding( + this.config, + this.flags, + { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, + args.kind === 'reviewer' ? 'primary' : undefined, + ); + let handle: SubagentHandle; + try { + handle = await this.launch(prompt, description, toolCallId, controller, binding); + } catch (error) { + return { + output: `tower spawn failed: ${error instanceof Error ? error.message : String(error)}`, + isError: true, + }; + } + + let taskId: string; + try { + taskId = this.tasks.registerTask(new SubagentTask(handle, description, controller), { + detached: true, + timeoutMs: DEFAULT_SUBAGENT_TIMEOUT_MS, + signal: undefined, + }); + } catch (error) { + controller.abort(); + void handle.completion.catch(() => {}); + return { + output: error instanceof Error ? error.message : String(error), + isError: true, + }; + } + void handle.completion + .catch(() => {}) + .finally(() => { + this.rateLimit.release(); + }); + slotHeld = false; + + await store.registerAgent({ + name: args.name, + agentId: handle.agentId, + sessionId: this.sessionContext.sessionId, + kind: args.kind, + missionId: mission?.id, + reviewTarget, + worktree: mission?.worktree, + branch: mission?.branch, + spawnedAt: new Date().toISOString(), + }); + if (mission !== undefined) { + // Only once the spawn is real (gate passed, task registered, roster + // written): marking the mission active+owned any earlier would leave + // a phantom owner behind when the gate or the launch fails. Silent: + // the spawn log line below already carries name/owner/mission — a + // second mission.update line for the same assignment is pure noise. + await store.updateMission( + TOWER_NAME, + mission.id, + { status: 'active', owner: args.name }, + { silent: true }, + ); + } + await store.appendLog( + TOWER_NAME, + 'spawn', + { + name: args.name, + kind: args.kind, + agent: handle.agentId, + mission: mission?.id, + target: reviewTarget, + model: binding?.model, + }, + mission !== undefined + ? join(MISSIONS_DIR, missionFileName(mission.id, mission.slug)) + : undefined, + ); + + return { + output: [ + `name: ${args.name}`, + `kind: ${args.kind}`, + `agent_id: ${handle.agentId}`, + `task_id: ${taskId}`, + 'status: running', + ...(binding !== undefined ? [`model: ${binding.model}`] : []), + ...(mission !== undefined + ? [ + `mission: ${mission.id} — ${mission.title}`, + `branch: ${mission.branch}`, + `worktree: ${store.abs(join(WORKTREES_DIR, mission.worktree))}`, + ] + : [`review_target: ${reviewTarget ?? ''}`]), + ...notes, + '', + `The ${args.kind} runs detached in the background; its completion arrives as a notification. Track progress with TowerStatus / TowerInbox; recover a dead agent with Agent(resume="${handle.agentId}", prompt="...").`, + ].join('\n'), + }; + } finally { + if (slotHeld) this.rateLimit.release(); + } + } catch (error) { + // Expected protocol/git failures surface as error results — their + // messages are written as next-step guidance for the model. Unexpected + // (programming) errors keep propagating. + if (error instanceof TowerProtocolError || error instanceof GitError) { + return { output: error.message, isError: true }; + } + throw error; + } + } + + private async launch( + prompt: string, + description: string, + toolCallId: string, + controller: AbortController, + binding: SubagentBinding | undefined, + ): Promise { + const requester = this.lifecycle.get(this.callerAgentId); + if (requester === undefined) { + throw new Error(`Caller agent "${this.callerAgentId}" does not exist`); + } + + let created: IAgentScopeHandle; + try { + // Validate the bound alias up front so a dangling [secondary_model] + // pointer fails the spawn here, not mid-turn inside the worker. + if (binding !== undefined) this.modelCatalog.get(binding.model); + created = await this.lifecycle.create({ + binding: { + profile: TOWER_WORKER_PROFILE, + model: binding?.model, + thinking: binding?.thinking, + }, + labels: subagentLabels(this.callerAgentId), + }); + } catch (error) { + throw binding === undefined + ? error + : wrapSubagentModelError(error, binding.model, this.profile.data().modelAlias); + } + // Pin the spawned agent to auto (see the file header): the fleet runs + // unattended, and broadcastPermissionMode will not move it off auto later. + created.accessor.get(IAgentPermissionModeService).setMode('auto'); + const agentId = created.id; + + emitAgentRunSpawned(requester, agentId, { + profileName: TOWER_WORKER_PROFILE, + parentToolCallId: toolCallId, + description, + runInBackground: true, + }); + + const run = await this.subagents.run( + agentId, + { kind: 'prompt', prompt }, + { signal: controller.signal }, + ); + const mirrored = mirrorAgentRun(requester, run, { + profileName: TOWER_WORKER_PROFILE, + prompt, + signal: controller.signal, + cancel: (reason) => { + controller.abort(reason); + }, + }); + return { + agentId, + profileName: TOWER_WORKER_PROFILE, + completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })), + }; + } + + /** Briefings are code-assembled — the tower LLM only supplies `instructions`. */ + private async buildPrompt( + args: TowerSpawnToolInput, + store: TowerStore, + state: TowerState, + mission: TowerMission | undefined, + reviewTarget: string | undefined, + ): Promise { + const extra = + args.instructions !== undefined && args.instructions.trim().length > 0 + ? `\n\n# Additional instructions from the tower\n${args.instructions.trim()}` + : ''; + if (mission !== undefined) { + const missionText = await readFile( + store.abs(join(MISSIONS_DIR, missionFileName(mission.id, mission.slug))), + 'utf8', + ); + const worktreeAbs = store.abs(join(WORKTREES_DIR, mission.worktree)); + const workplace = + `# Your workplace\n` + + `- Your private git worktree: ${worktreeAbs}\n` + + `- Your branch: ${mission.branch} (base: ${state.base})\n` + + `- Your working directory is the main checkout, NOT your worktree — address the worktree explicitly: every Read/Write/Edit/Grep/Glob path must be absolute and under ${worktreeAbs}, and every Bash command must \`cd ${worktreeAbs}\` first. A permission guard hard-denies any Write/Edit outside it. Never touch the main checkout (${store.repoRoot}) or another agent's worktree slot.\n` + + (mission.kind === 'survey' + ? `- Scope — what you investigate (read-only; reserves nothing): ${mission.scope.join(', ')}\n\n` + : `- Scope — the only files you may change: ${mission.scope.join(', ')}\n\n`); + if (mission.kind === 'survey') { + return ( + `You are "${args.name}", a tower worker agent in a multi-agent workspace, assigned a READ-ONLY survey mission.\n\n` + + workplace + + `# Your mission\n\n${missionText.trim()}\n\n` + + `# Read-only discipline\n` + + '- Your scope marks what you investigate, not what you may change. You MUST NOT modify, add, or delete any file in the repo, and your branch must end with zero commits — a changed file makes the merge gate reject your mission as a read-only violation.\n' + + '- Your deliverables are knowledge: record findings as TowerMission notes, send summaries to the tower and to dependent agents with TowerSend, and file TowerFinding for out-of-scope discoveries.\n\n' + + `# Communication protocol\n` + + '- Coordinate through tower tools ONLY: TowerSend / TowerInbox / TowerFinding / TowerMission / TowerStatus. Reach the tower and sibling agents with TowerSend; check TowerInbox regularly.\n' + + '- NEVER create or edit files under `.tower/` by hand — the tools are the only writers.\n\n' + + `# When the survey is done\n` + + `1. Mark the mission completed: TowerMission(id="${mission.id}", status="completed").\n` + + '2. Send the tower your summary: TowerSend(to="tower", subject="survey-summary", body=the full survey result).\n' + + '3. Finish with a structured final summary: what you covered, key facts with file:line references, open questions.' + + extra + ); + } + return ( + `You are "${args.name}", a tower worker agent in a multi-agent workspace.\n\n` + + workplace + + `# Your mission\n\n${missionText.trim()}\n\n` + + `# Communication protocol\n` + + '- Coordinate through tower tools ONLY: TowerSend / TowerInbox / TowerFinding / TowerMission / TowerStatus. Reach the tower and sibling agents with TowerSend; check TowerInbox regularly.\n' + + '- NEVER create or edit files under `.tower/` by hand — the tools are the only writers; hand-written protocol files break the merge gate.\n' + + '- Found something notable outside your scope? File it with TowerFinding instead of fixing it.\n' + + '- Keep your mission current with TowerMission: task_done as you finish tasks, note for decisions, blocker when stuck.\n\n' + + `# When the mission is done\n` + + '1. `git add` + `git commit` everything in your worktree (and `git push` only if a remote is configured).\n' + + `2. Mark the mission completed: TowerMission(id="${mission.id}", status="completed").\n` + + '3. Request review: TowerSend(to="tower", subject="review-request", body=what you changed and why).\n' + + '4. Finish with a structured final summary: files changed, key decisions, open follow-ups.' + + extra + ); + } + const target = reviewTarget ?? ''; + const author = state.missions.find((m) => m.branch === target)?.owner; + return ( + `You are "${args.name}", a tower reviewer agent in a multi-agent workspace.\n\n` + + `# Your assignment\n` + + `Review branch "${target}" against base "${state.base}".\n` + + `- Work read-only in the main checkout (${store.repoRoot}): \`git diff ${state.base}...${target}\`, \`git log ${state.base}..${target}\`, and read files as needed.\n` + + '- Do NOT modify any code, and never create or edit files under `.tower/` by hand — protocol artifacts go through the tower tools.\n\n' + + `# Review checklist (in priority order)\n` + + '1. Security\n2. Data integrity\n3. Performance\n4. Error handling\n5. Code quality\n\n' + + `# When done — both steps are mandatory\n` + + `1. Submit your verdict with TowerReview: { target: "${target}", status: "clean" | "p1-Nitems" | "p2-Nitems", merge: "merge" | "fix-then-merge" | "hold", findings, checks, decision }. Only a "clean" review of the exact branch tip lets the tower merge.\n` + + (author !== undefined + ? `2. Notify the author with TowerSend(to="${author}", subject="review-result", ...).\n` + : '2. The author of this branch is not recorded — notify the tower instead: TowerSend(to="tower", subject="review-result", ...).\n') + + 'Then finish with a structured summary of the review.' + + extra + ); + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/status/status.md b/packages/agent-core-v2/src/features/tower/tools/status/status.md new file mode 100644 index 0000000000..25ea7bb718 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/status/status.md @@ -0,0 +1 @@ +Show the tower dashboard: missions (status/owner), the agent roster, the review-gate state of every unmerged branch (latest review round/status and whether the reviewed commit still matches the branch tip), your inbox message count, and the last activity log lines. diff --git a/packages/agent-core-v2/src/features/tower/tools/status/status.ts b/packages/agent-core-v2/src/features/tower/tools/status/status.ts new file mode 100644 index 0000000000..8d20b31d92 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/status/status.ts @@ -0,0 +1,24 @@ +/** + * `tools` domain — `ITowerStatusTool` contract (the `TowerStatus` tool). + * + * Public contract of the shared tower dashboard: mission table, roster, + * per-branch review-gate state (latest review round/status and whether it + * still matches the branch tip), the caller's inbox count, and the recent + * activity log. Exports the model-facing `TowerStatusToolInputSchema` / + * `TowerStatusToolInput` and the `ITowerStatusTool` DI decorator. Bound at + * Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerStatusToolInputSchema = z.object({}).strict(); + +export type TowerStatusToolInput = z.infer; + +export interface ITowerStatusTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerStatusTool = createDecorator('towerStatusTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts b/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts new file mode 100644 index 0000000000..e0e8f608fa --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/status/statusTool.ts @@ -0,0 +1,182 @@ +/** + * `tools` domain — `TowerStatusTool` implementation (the `TowerStatus` + * tool). + * + * Renders the dashboard from the protocol `TowerStore` rooted at the session + * cwd (`sessionContext`), resolving the caller's roster identity from the + * agent scope (`scopeContext`) and the spawn-concurrency section from the + * rate limiter (`towerRateLimit`). Registered for every agent — visibility + * is controlled by profile tool lists. Bound at Agent scope. + */ + +import { branchExists, branchTip } from '#/features/tower/protocol/index'; +import type { TowerMission, TowerState, TowerStore } from '#/features/tower/protocol/index'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { + ITowerRateLimitService, + type TowerRateLimitSnapshot, +} from '#/features/tower/towerRateLimit'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { callerName, newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './status.md?raw'; +import { + ITowerStatusTool, + TowerStatusToolInputSchema, + type TowerStatusToolInput, +} from './status'; + +const STATUS_EMOJI: Record = { + planned: '🟡', + active: '🔵', + completed: '🟢', + blocked: '🔴', + paused: '⏸️', + merged: '✅', +}; + +const INBOX_COUNT_LIMIT = 1000; +const RECENT_LOG_LINES = 10; + +export class TowerStatusTool implements ITowerStatusTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerStatus' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerStatusToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ITowerRateLimitService private readonly rateLimit: ITowerRateLimitService, + ) {} + + resolveExecution(_args: TowerStatusToolInput): ToolExecution { + return { + description: 'Reading tower status', + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const state = await store.load(); + const caller = callerName(this.scopeContext.agentId, store, state); + + const sections: string[] = [ + `# Tower status — base: ${state.base} (mode: ${state.mode}), you are: ${caller}`, + '', + '## Missions', + '', + ...renderMissions(state), + '', + '## Roster', + '', + ...renderRoster(state), + '', + '## Review gate (unmerged branches)', + '', + ...(await this.renderReviewGate(store, state)), + ]; + + if ( + state.missions.length > 0 && + state.missions.every((mission) => mission.status === 'merged') + ) { + sections.push( + '', + '## Done', + '', + 'All missions are merged. Free the worktree checkouts now: run TowerTeardown (branches and .tower/comms/ are kept; dirty worktrees are protected).', + ); + } + + const inbox = await store.readInbox(caller, INBOX_COUNT_LIMIT); + sections.push( + '', + '## Inbox', + '', + `${String(inbox.length)} message(s) visible to you — read with TowerInbox.`, + '', + '## Concurrency (adaptive)', + '', + renderConcurrency(this.rateLimit.snapshot()), + '', + '## Recent activity', + '', + ); + const log = await store.recentLog(RECENT_LOG_LINES); + sections.push(...(log.length > 0 ? log : ['(activity log is empty)'])); + return { output: sections.join('\n') }; + }), + }; + } + + private async renderReviewGate(store: TowerStore, state: TowerState): Promise { + const pending = state.missions.filter((m) => m.status !== 'merged'); + if (pending.length === 0) return ['(all missions merged — or none planned yet)']; + const lines: string[] = []; + for (const mission of pending) { + const review = await store.latestReview(mission.branch); + if (review === undefined) { + lines.push(`- ${mission.branch} (${mission.id}): no review yet`); + continue; + } + const tip = (await branchExists(store.repoRoot, mission.branch)) + ? await branchTip(store.repoRoot, mission.branch) + : undefined; + const sync = + tip === undefined + ? 'branch not created yet' + : tip === review.reviewedCommit + ? 'reviewed commit matches tip' + : `STALE — tip moved to ${tip.slice(0, 7)}, re-review required`; + lines.push( + `- ${mission.branch} (${mission.id}): round ${String(review.round)} by ${review.reviewer} — ${review.status} (${sync})`, + ); + } + return lines; + } +} + +function renderConcurrency(snapshot: TowerRateLimitSnapshot): string { + const parts = [ + `budget: ${String(snapshot.budget)} agent(s) · inflight: ${String(snapshot.inflight)}`, + ]; + if (snapshot.blockedUntil !== null) { + const remainingMs = snapshot.blockedUntil - Date.now(); + parts.push( + remainingMs > 0 + ? `spawns PAUSED for ~${String(Math.ceil(remainingMs / 1000))}s (provider rate limit — successful requests lift the pause early)` + : 'spawn pause expired — budget probing resumes', + ); + } else { + parts.push('spawns open'); + } + return parts.join(' · '); +} + +function renderMissions(state: TowerState): string[] { + if (state.missions.length === 0) return ['(no missions planned — use TowerPlan)']; + return [ + '| ID | Mission | Branch | Worktree | Status | Owner |', + '| -- | ------- | ------ | -------- | ------ | ----- |', + ...state.missions.map( + (m) => + `| ${m.id} | ${m.title}${m.kind === 'survey' ? ' 🔍' : ''} | ${m.branch} | ${m.worktree} | ${STATUS_EMOJI[m.status]} ${m.status} | ${m.owner ?? '—'} |`, + ), + ]; +} + +function renderRoster(state: TowerState): string[] { + if (state.roster.agents.length === 0) { + return ['(no agents registered — spawn workers/reviewers with TowerSpawn)']; + } + return state.roster.agents.map((a) => { + const assignment = + a.kind === 'worker' + ? `mission ${a.missionId ?? '?'} (branch ${a.branch ?? '?'}, worktree ${a.worktree ?? '?'})` + : `reviewing ${a.reviewTarget ?? '?'}`; + return `- ${a.name} (${a.kind}) — agent ${a.agentId}, ${assignment}`; + }); +} + diff --git a/packages/agent-core-v2/src/features/tower/tools/support.ts b/packages/agent-core-v2/src/features/tower/tools/support.ts new file mode 100644 index 0000000000..dd67506231 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/support.ts @@ -0,0 +1,48 @@ +/** + * `tools` domain — shared helpers for the tower tool set: store construction + * anchored at the session's working directory, caller identity resolution + * against the roster, and uniform error mapping. The tower workspace always + * anchors at the main checkout — workers whose cwd was overridden to their + * worktree still talk to the same `.tower/` tree. + */ + +import { + GitError, + TowerProtocolError, + TowerStore, + resolveTowerRepoRoot, + type TowerState, +} from '#/features/tower/protocol/index'; +import type { ISessionContext } from '#/session/sessionContext/sessionContext'; +import type { ExecutableToolResult } from '#/tool/toolContract'; + +/** The store root is the main checkout holding `.tower/`. */ +export function newTowerStore(sessionContext: ISessionContext): TowerStore { + return new TowerStore(resolveTowerRepoRoot(sessionContext.cwd)); +} + +/** + * Resolve the caller's tower identity. The main agent is the control tower; + * a spawned worker/reviewer is looked up in the roster by its agent id. + */ +export function callerName(agentId: string, store: TowerStore, state: TowerState): string { + return store.resolveCallerName(state, agentId); +} + +/** + * Run a tower tool body, mapping expected protocol/git failures to error + * results — their messages are written as next-step guidance for the model. + * Unexpected (programming) errors keep propagating. + */ +export async function runTowerTool( + execute: () => Promise, +): Promise { + try { + return await execute(); + } catch (error) { + if (error instanceof TowerProtocolError || error instanceof GitError) { + return { output: error.message, isError: true }; + } + throw error; + } +} diff --git a/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.md b/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.md new file mode 100644 index 0000000000..9e9a6bfee4 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.md @@ -0,0 +1,3 @@ +Tear down the tower workspace after all missions are merged (or abandoned). + +Removes the mission worktrees — worktrees with uncommitted changes are kept and listed unless force is set. Exits tower mode. The .tower/comms/ directory (state, inbox, findings, reviews, activity log) is always kept as the audit trail. diff --git a/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.ts b/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.ts new file mode 100644 index 0000000000..9d4bb4db9c --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/teardown/teardown.ts @@ -0,0 +1,31 @@ +/** + * `tools` domain — `ITowerTeardownTool` contract (the `TowerTeardown` tool). + * + * Public contract of the tower session ender: removes mission worktrees + * (dirty ones are kept unless force), exits tower mode, and reports what + * happened. The comms directory stays on disk as the audit trail. Exports + * the model-facing `TowerTeardownToolInputSchema` / + * `TowerTeardownToolInput` and the `ITowerTeardownTool` DI decorator. Bound + * at Agent scope. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const TowerTeardownToolInputSchema = z + .object({ + force: z + .boolean() + .optional() + .describe('Remove worktrees even when they contain uncommitted changes'), + }) + .strict(); + +export type TowerTeardownToolInput = z.infer; + +export interface ITowerTeardownTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const ITowerTeardownTool = createDecorator('towerTeardownTool'); diff --git a/packages/agent-core-v2/src/features/tower/tools/teardown/teardownTool.ts b/packages/agent-core-v2/src/features/tower/tools/teardown/teardownTool.ts new file mode 100644 index 0000000000..3387f88218 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tools/teardown/teardownTool.ts @@ -0,0 +1,56 @@ +/** + * `tools` domain — `TowerTeardownTool` implementation (the `TowerTeardown` + * tool). + * + * Tears the workspace down through the protocol `TowerStore` rooted at the + * session cwd (`sessionContext`) and exits tower mode via `tower`; the comms + * directory stays on disk as the audit trail. Registered for the main agent + * only. Bound at Agent scope. + */ + +import { IAgentTowerService } from '#/features/tower/tower'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import type { ToolExecution } from '#/tool/toolContract'; + +import { newTowerStore, runTowerTool } from '../support'; +import DESCRIPTION from './teardown.md?raw'; +import { + ITowerTeardownTool, + TowerTeardownToolInputSchema, + type TowerTeardownToolInput, +} from './teardown'; + +export class TowerTeardownTool implements ITowerTeardownTool { + declare readonly _serviceBrand: undefined; + readonly name = 'TowerTeardown' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TowerTeardownToolInputSchema); + + constructor( + @ISessionContext private readonly sessionContext: ISessionContext, + @IAgentTowerService private readonly tower: IAgentTowerService, + ) {} + + resolveExecution(args: TowerTeardownToolInput): ToolExecution { + return { + description: `Tearing down tower workspace${args.force === true ? ' (force)' : ''}`, + approvalRule: this.name, + execute: () => + runTowerTool(async () => { + const store = newTowerStore(this.sessionContext); + const report = await store.teardown({ force: args.force }); + this.tower.exit(); + return { + output: [ + 'tower teardown:', + ...report.map((line) => `- ${line}`), + '', + 'Tower mode exited. .tower/comms/ (state, inbox, findings, reviews, activity log) is kept as the audit trail — remove it by hand only if you are sure.', + ].join('\n'), + }; + }), + }; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/tower-worker-overlay.md b/packages/agent-core-v2/src/features/tower/tower-worker-overlay.md new file mode 100644 index 0000000000..9f3a0c8bdd --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tower-worker-overlay.md @@ -0,0 +1 @@ +You are a tower worker/reviewer in a multi-agent tower workspace. All collaboration protocol traffic (inbox messages, findings, reviews, mission updates) goes through the Tower* tools ONLY — never create, edit, or delete any file under `.tower/` by hand; the tools are the only writers, and hand-written protocol files break the merge gate. Your TowerSpawn briefing names your mission (worker) or review target (reviewer) — stay inside it. diff --git a/packages/agent-core-v2/src/features/tower/tower.ts b/packages/agent-core-v2/src/features/tower/tower.ts new file mode 100644 index 0000000000..70ca721897 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/tower.ts @@ -0,0 +1,39 @@ +/** + * `tower` domain — the `IAgentTowerService` contract: the session-scoped + * on/off flag marking this agent as the control tower of an active tower + * session, plus `TOWER_TOOL_NAMES`, the tool set TowerInit activates on + * entry. Bound at Agent scope. + */ + +import { createDecorator } from "#/_base/di/instantiation"; + +export const TOWER_TOOL_NAMES = [ + 'TowerPlan', + 'TowerSpawn', + 'TowerMerge', + 'TowerTeardown', + 'TowerSend', + 'TowerInbox', + 'TowerFinding', + 'TowerReview', + 'TowerMission', + 'TowerStatus', +] as const; + +/** + * Profile name of tower-spawned worker/reviewer agents. TowerSpawn pins these + * agents to the `auto` permission mode at spawn (they run detached and + * unattended), and `broadcastPermissionMode` skips them, so a session-wide + * mode switch never moves them off `auto`. + */ +export const TOWER_WORKER_PROFILE = 'tower-worker'; + +export interface IAgentTowerService { + readonly _serviceBrand: undefined; + + readonly isActive: boolean; + enter(): void; + exit(): void; +} + +export const IAgentTowerService = createDecorator('agentTowerService'); diff --git a/packages/agent-core-v2/src/features/tower/towerFeature.ts b/packages/agent-core-v2/src/features/tower/towerFeature.ts new file mode 100644 index 0000000000..7ebee38611 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/towerFeature.ts @@ -0,0 +1,104 @@ +/** + * `tower` domain — `TowerFeature`: multi-agent tower orchestration assembled + * as one App-scope Feature unit. + * + * Contributes the App-scope `ITowerRateLimitService` (provider-concurrency + * governor), the eleven `Tower*` agent tools, and the `tower-worker` agent + * profile through the `features` base-class seams; retracting the unit + * withdraws all of them across the scope tree. `TowerInit`/`TowerPlan`/ + * `TowerSpawn`/`TowerMerge`/`TowerTeardown` are gated to the main agent (the + * tower itself); the rest serve workers and reviewers. The `tower` wire + * vocabulary (`features/tower/towerOps`) and `IAgentTowerService` (the + * tower-mode write guard must be live from agent-scope creation) stay on + * their static import=register channels — wire records must remain + * replayable even when the feature unit is retracted. Registered into the + * feature table at import. + */ + +import { ScopeActivation } from '#/_base/di/instantiation'; +import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import type { + AgentToolCtor, + AnyAgentTool, +} from '#/agent/toolRegistry/toolContribution'; +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { ITowerRateLimitService } from './towerRateLimit'; +import { TowerRateLimitService } from './towerRateLimitService'; +import { ITowerFindingTool } from './tools/finding/finding'; +import { TowerFindingTool } from './tools/finding/findingTool'; +import { ITowerInboxTool } from './tools/inbox/inbox'; +import { TowerInboxTool } from './tools/inbox/inboxTool'; +import { ITowerInitTool } from './tools/init/init'; +import { TowerInitTool } from './tools/init/initTool'; +import { ITowerMergeTool } from './tools/merge/merge'; +import { TowerMergeTool } from './tools/merge/mergeTool'; +import { ITowerMissionTool } from './tools/mission/mission'; +import { TowerMissionTool } from './tools/mission/missionTool'; +import { ITowerPlanTool } from './tools/plan/plan'; +import { TowerPlanTool } from './tools/plan/planTool'; +import { ITowerReviewTool } from './tools/review/review'; +import { TowerReviewTool } from './tools/review/reviewTool'; +import { ITowerSendTool } from './tools/send/send'; +import { TowerSendTool } from './tools/send/sendTool'; +import { ITowerSpawnTool } from './tools/spawn/spawn'; +import { TowerSpawnTool } from './tools/spawn/spawnTool'; +import { ITowerStatusTool } from './tools/status/status'; +import { TowerStatusTool } from './tools/status/statusTool'; +import { ITowerTeardownTool } from './tools/teardown/teardown'; +import { TowerTeardownTool } from './tools/teardown/teardownTool'; +import { TOWER_WORKER_PROFILE_DEF } from './workerProfile'; + +/** Tower-orchestration tools exist only on the main agent (the tower). */ +const towerOnly = (accessor: ServicesAccessor): boolean => + accessor.get(IAgentScopeContext).agentId === 'main'; + +/** + * The tower tool registration contract — name, implementation, and the + * main-agent gate, as data so tests can assert the gating without assembling + * the feature unit. + */ +interface TowerToolContribution { + readonly id: ServiceIdentifier; + readonly ctor: AgentToolCtor; + readonly name: string; + readonly when?: (accessor: ServicesAccessor) => boolean; +} + +export const TOWER_TOOL_CONTRIBUTIONS: readonly TowerToolContribution[] = [ + { id: ITowerInitTool, ctor: TowerInitTool, name: 'TowerInit', when: towerOnly }, + { id: ITowerPlanTool, ctor: TowerPlanTool, name: 'TowerPlan', when: towerOnly }, + { id: ITowerSpawnTool, ctor: TowerSpawnTool, name: 'TowerSpawn', when: towerOnly }, + { id: ITowerMergeTool, ctor: TowerMergeTool, name: 'TowerMerge', when: towerOnly }, + { id: ITowerTeardownTool, ctor: TowerTeardownTool, name: 'TowerTeardown', when: towerOnly }, + { id: ITowerSendTool, ctor: TowerSendTool, name: 'TowerSend' }, + { id: ITowerInboxTool, ctor: TowerInboxTool, name: 'TowerInbox' }, + { id: ITowerFindingTool, ctor: TowerFindingTool, name: 'TowerFinding' }, + { id: ITowerReviewTool, ctor: TowerReviewTool, name: 'TowerReview' }, + { id: ITowerMissionTool, ctor: TowerMissionTool, name: 'TowerMission' }, + { id: ITowerStatusTool, ctor: TowerStatusTool, name: 'TowerStatus' }, +]; + +export class TowerFeature extends Feature { + static override readonly name = 'tower'; + + constructor() { + super(); + this.contributeService(LifecycleScope.App, ITowerRateLimitService, TowerRateLimitService, { + activation: ScopeActivation.OnDemand, + }); + for (const tool of TOWER_TOOL_CONTRIBUTIONS) { + this.contributeTool(tool.id, tool.ctor, { + name: tool.name, + domain: 'tower', + when: tool.when, + }); + } + this.contributeProfiles([TOWER_WORKER_PROFILE_DEF]); + } +} + +registerFeature(TowerFeature); diff --git a/packages/agent-core-v2/src/features/tower/towerOps.ts b/packages/agent-core-v2/src/features/tower/towerOps.ts new file mode 100644 index 0000000000..f6f5d0d56a --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/towerOps.ts @@ -0,0 +1,36 @@ +/** + * `tower` domain — wire Model (`TowerModel`) and the `tower_mode.enter` / + * `tower_mode.exit` Ops (`towerEnter` / `towerExit`) for the agent's tower + * mode. + * + * Declares tower mode as a boolean wire Model plus the two Ops that set and + * clear it — v1's `tower_mode.*` records carry no payload, so replaying a + * legacy session restores the flag through these Ops with no dedicated + * restore path. Each Op's `toEvent` publishes the `towerMode` slice of + * `agent.status.updated` on the live path. + */ + +import { z } from 'zod'; + +import { defineModel } from '#/wire/model'; + +export const TowerModel = defineModel('tower', () => false); + +declare module '#/wire/types' { + interface PersistedOpMap { + 'tower_mode.enter': typeof towerEnter; + 'tower_mode.exit': typeof towerExit; + } +} + +export const towerEnter = TowerModel.defineOp('tower_mode.enter', { + schema: z.object({}), + apply: () => true, + toEvent: () => ({ type: 'agent.status.updated' as const, towerMode: true }), +}); + +export const towerExit = TowerModel.defineOp('tower_mode.exit', { + schema: z.object({}), + apply: () => false, + toEvent: () => ({ type: 'agent.status.updated' as const, towerMode: false }), +}); diff --git a/packages/agent-core-v2/src/features/tower/towerRateLimit.ts b/packages/agent-core-v2/src/features/tower/towerRateLimit.ts new file mode 100644 index 0000000000..376bb21d44 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/towerRateLimit.ts @@ -0,0 +1,31 @@ +/** + * `tower` domain — the `ITowerRateLimitService` contract: the process-wide + * provider-concurrency governor for tower spawns (spawn budget, inflight + * tracking, post-429 spawn pause). Bound at App scope. + */ + +import { createDecorator } from '#/_base/di/instantiation'; + +export interface TowerRateLimitSnapshot { + /** Effective tower spawn budget: governor capacity clamped to the max. */ + readonly budget: number; + /** Tower agents currently running (acquired, not yet released). */ + readonly inflight: number; + /** Epoch ms while which new spawns are refused; null when unblocked. */ + readonly blockedUntil: number | null; +} + +export interface ITowerRateLimitService { + readonly _serviceBrand: undefined; + + reportRateLimited(): void; + reportSuccess(): void; + budget(): number; + acquire(): { readonly ok: true } | { readonly ok: false; readonly reason: string }; + release(): void; + snapshot(): TowerRateLimitSnapshot; + reset(): void; +} + +export const ITowerRateLimitService = + createDecorator('towerRateLimitService'); diff --git a/packages/agent-core-v2/src/features/tower/towerRateLimitService.ts b/packages/agent-core-v2/src/features/tower/towerRateLimitService.ts new file mode 100644 index 0000000000..00a76fa171 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/towerRateLimitService.ts @@ -0,0 +1,165 @@ +/** + * `tower` domain — `ITowerRateLimitService` implementation. + * + * The tower face of the provider-concurrency capacity machine, process-wide + * like v1's `towerRateLimiter` singleton: an inflight acquire/release + * counter, a short spawn pause after each 429 (lifted early by the next + * success), and a recovery ceiling (`TOWER_MAX_BUDGET`). The underlying + * algorithm is the one proven in `SubagentBatch`: uncapped until the first + * provider 429, then capacity snaps to (what was actually running − 1), + * shrinks by one per subsequent 429 (throttled so a burst is one episode), + * and recovers +1 per quiet window without 429s. NOTE: nothing feeds + * `reportRateLimited` / `reportSuccess` yet — v2 has no retry funnel + * (v1's `chatWithRetry`) to report 429s and successes through; that + * integration is pending. Bound at App scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { + ITowerRateLimitService, + type TowerRateLimitSnapshot, +} from './towerRateLimit'; + +export const RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS = 2_000; +export const RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS = 180_000; +/** Tower-only: how long new spawns stay paused after a 429 episode. */ +export const TOWER_SPAWN_PAUSE_MS = 60_000; +/** Tower-only: ceiling the capacity may recover to. */ +export const TOWER_MAX_BUDGET = 16; + +export class RateLimitCapacityGovernor { + private capacity = Number.POSITIVE_INFINITY; + private lastRateLimitAt: number | undefined; + private lastShrinkAt: number | undefined; + private lastRecoveryAt: number | undefined; + + constructor(private readonly now: () => number = Date.now) {} + + getCapacity(): number { + return this.capacity; + } + + get inBackoff(): boolean { + return this.lastRateLimitAt !== undefined; + } + + get lastRateLimitedAt(): number | undefined { + return this.lastRateLimitAt; + } + + noteRateLimited(activeCount: number): void { + const now = this.now(); + if (activeCount > 0) { + if (this.capacity === Number.POSITIVE_INFINITY) { + this.capacity = Math.max(1, activeCount - 1); + this.lastShrinkAt = now; + } else if ( + this.lastShrinkAt === undefined || + now - this.lastShrinkAt >= RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS + ) { + this.capacity = Math.max(1, this.capacity - 1); + this.lastShrinkAt = now; + } + } + this.lastRateLimitAt = now; + } + + maybeRecover(): boolean { + const now = this.now(); + if (this.nextRecoveryAt() > now) return false; + this.capacity += 1; + this.lastRecoveryAt = now; + return true; + } + + nextRecoveryAt(): number { + if (this.lastRateLimitAt === undefined) return Number.POSITIVE_INFINITY; + return ( + Math.max(this.lastRateLimitAt, this.lastRecoveryAt ?? 0) + + RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS + ); + } + + reset(): void { + this.capacity = Number.POSITIVE_INFINITY; + this.lastRateLimitAt = undefined; + this.lastShrinkAt = undefined; + this.lastRecoveryAt = undefined; + } +} + +export class TowerRateLimitService extends Disposable implements ITowerRateLimitService { + declare readonly _serviceBrand: undefined; + + private readonly governor: RateLimitCapacityGovernor; + private readonly now: () => number; + private inflight = 0; + private blockedUntil: number | null = null; + + constructor(now: () => number = Date.now) { + super(); + this.now = now; + this.governor = new RateLimitCapacityGovernor(this.now); + } + + reportRateLimited(): void { + this.governor.noteRateLimited(this.inflight); + this.blockedUntil = this.now() + TOWER_SPAWN_PAUSE_MS; + } + + reportSuccess(): void { + this.blockedUntil = null; + this.governor.maybeRecover(); + } + + budget(): number { + this.governor.maybeRecover(); + return Math.max(1, Math.min(TOWER_MAX_BUDGET, this.governor.getCapacity())); + } + + acquire(): { readonly ok: true } | { readonly ok: false; readonly reason: string } { + const now = this.now(); + if (this.blockedUntil !== null) { + if (now < this.blockedUntil) { + const retryAfterS = Math.ceil((this.blockedUntil - now) / 1000); + return { + ok: false, + reason: + `provider rate limit hit — new tower spawns paused for ~${String(retryAfterS)}s. ` + + 'Successful requests lift the pause early; wait and retry, or let running agents finish first.', + }; + } + this.blockedUntil = null; + } + const budget = this.budget(); + if (this.inflight >= budget) { + return { + ok: false, + reason: + `tower concurrency budget exhausted (${String(this.inflight)}/${String(budget)} agents running). ` + + 'Wait for a running agent to complete, then retry.', + }; + } + this.inflight += 1; + return { ok: true }; + } + + release(): void { + this.inflight = Math.max(0, this.inflight - 1); + } + + snapshot(): TowerRateLimitSnapshot { + return { + budget: this.budget(), + inflight: this.inflight, + blockedUntil: this.blockedUntil, + }; + } + + reset(): void { + this.governor.reset(); + this.inflight = 0; + this.blockedUntil = null; + } +} + diff --git a/packages/agent-core-v2/src/features/tower/towerService.ts b/packages/agent-core-v2/src/features/tower/towerService.ts new file mode 100644 index 0000000000..8e657b4f32 --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/towerService.ts @@ -0,0 +1,140 @@ +/** + * `tower` domain — `IAgentTowerService` implementation. + * + * Tracks tower-mode enter/exit in the `wire` `TowerModel` (mutated only + * through the `tower_mode.enter` / `tower_mode.exit` Ops, read through + * `wire.getModel`), and derives the `towerMode` slice of + * `agent.status.updated` from the Ops' `toEvent`. Also carries the + * tower-mode harness constraints as `onBeforeExecuteTool` veto listeners. + * The first denies `TodoList` while tower mode is active: mission state + * lives in the tower protocol, and todo semantics ("keep exactly one task + * in_progress") would serialize a fleet that exists to run in parallel — + * tower mode is per-agent, so this only ever fires for the tower itself and + * workers keep their TodoList. The second is the tower-worker write guard + * (port of v1's `tower-worker-write-guard-deny` + * policy): a `tower-worker`-profile agent's Write/Edit is confined to the + * worktree its roster entry records (`.tower/worktrees/` under the + * repo root, resolved through the `tower` protocol store from + * `sessionContext.cwd`); any declared write access outside it is vetoed with + * the v1 message verbatim. v1 keyed the confinement on the worker's cwd + * override, which was always set; v2 has no per-agent cwd, so a worker + * without a roster entry (or with no readable `.tower` state) is simply + * outside the protocol and the guard abstains. `AskUserQuestion` is + * deliberately not vetoed here: the tower (the main agent) may ask the human + * to clarify requirements, while workers and reviewers cannot ask at all — + * their `tower-worker` profile does not list the tool. Bound at Agent scope. + */ + +import { join } from 'node:path'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { LifecycleScope } from '#/app/scopes'; +import { isWithinDirectory } from '#/tool/path-access'; +import type { ToolFileAccess } from '#/tool/toolContract'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { IWireService } from '#/wire/wire'; +import { + TowerStore, + WORKTREES_DIR, + resolveTowerRepoRoot, +} from './protocol/index'; +import { IAgentTowerService, TOWER_WORKER_PROFILE } from './tower'; +import { towerEnter, towerExit, TowerModel } from './towerOps'; + +export class AgentTowerService extends Disposable implements IAgentTowerService { + declare readonly _serviceBrand: undefined; + + constructor( + @IWireService private readonly wire: IWireService, + @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, + @ISessionContext private readonly sessionCtx: ISessionContext, + ) { + super(); + this._register( + toolExecutor.onBeforeExecuteTool((event) => { + if (!this.isActive) return; + if (event.toolCall.name !== 'TodoList') return; + event.veto( + denyToolExecution( + this.toolApproval.formatDenyMessage( + 'TodoList is not available while tower mode is active — mission state lives in the tower protocol (TowerPlan/TowerMission/TowerStatus, MISSIONS.md), and todo semantics would serialize the fleet. Spawn every dependency-unblocked mission now, then end your turn: worker completions wake you.', + ), + ), + ); + }), + ); + this._register( + toolExecutor.onBeforeExecuteTool(async (event) => { + if (this.profile.data().profileName !== TOWER_WORKER_PROFILE) return; + const toolName = event.toolCall.name; + if (toolName !== 'Write' && toolName !== 'Edit') return; + + const store = new TowerStore(resolveTowerRepoRoot(this.sessionCtx.cwd)); + const entry = await store + .load() + .then( + (state) => + state.roster.agents.find((agent) => agent.agentId === this.agentCtx.agentId), + () => undefined, + ); + const slot = entry?.worktree; + if (slot === undefined) return; + const worktree = store.abs(join(WORKTREES_DIR, slot)); + + const escapes = (event.execution.accesses ?? []) + .filter( + (access): access is ToolFileAccess => + access.kind === 'file' && + (access.operation === 'write' || access.operation === 'readwrite'), + ) + .filter((access) => !isWithinDirectory(access.path, worktree)); + if (escapes.length === 0) return; + event.veto( + denyToolExecution( + this.toolApproval.formatDenyMessage( + `tower workers may only write inside their own worktree (${worktree}) — denied: ` + + `${escapes.map((access) => access.path).join(', ')}. ` + + 'Out-of-scope changes are not yours to make: file them with TowerFinding or ask the tower via TowerSend.', + ), + ), + ); + }), + ); + } + + enter(): void { + if (this.isActive) return; + this.wire.dispatch(towerEnter({})); + } + + exit(): void { + if (!this.isActive) return; + this.wire.dispatch(towerExit({})); + } + + get isActive(): boolean { + return this.wire.getModel(TowerModel); + } +} + +// The tower-mode write guard must be live from agent-scope creation, so this +// service stays on the static import=register channel instead of the Feature +// seam: a feature-contributed OnScopeCreated agent service materializes +// through the ScopeUnits cascade, which can run before the scope's static +// registrations (IEventBus) are visible. +registerScopedService( + LifecycleScope.Agent, + IAgentTowerService, + AgentTowerService, + ScopeActivation.OnScopeCreated, + 'tower', +); diff --git a/packages/agent-core-v2/src/features/tower/workerProfile.ts b/packages/agent-core-v2/src/features/tower/workerProfile.ts new file mode 100644 index 0000000000..938017515b --- /dev/null +++ b/packages/agent-core-v2/src/features/tower/workerProfile.ts @@ -0,0 +1,98 @@ +/** + * `tower` domain — the `tower-worker` agent profile: the profile TowerSpawn + * binds on every worker/reviewer agent. Self-contained like the builtin + * profiles — its structured `renderSystemPrompt` merges the shared base + * template with the worker role text at call time. + * + * The worker drops `AgentSwarm` from the coder tool set on purpose: the tower + * is the sole orchestrator, and a worker-side swarm fan-out would run + * unbudgeted (the tower rate limit only gates TowerSpawn) and outside the + * worktree/roster discipline — swarm children inherit the session cwd, the + * main checkout, bypassing the review-gated merge protocol. The same argument + * caps the remaining `Agent` delegation at read-only profiles + * (`subagents: ['explore', 'plan']`): a write-capable child would run on the + * main checkout outside the roster and the write guard. + * + * Contributed into the catalog by `towerFeature.ts` (the Feature seam), not + * by the builtin profile module. + */ + +import { + normalizeAgentProfile, + type AgentProfile, +} from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { + renderSystemPromptResult, + skillActiveFor, + TASK_AGENT_ROLE_PREFIX, +} from '#/app/agentProfileCatalog/profile-shared'; +// Read-only borrow of the shared summary-continuation prompt owned by the +// builtin profile module — keeps one source for the text. Relative path: +// the `#/` imports map cannot resolve `.md?raw` specifiers. +import SUMMARY_CONTINUATION_PROMPT from '../../session/agentLifecycle/profile/summary-continuation.md?raw'; + +import { TOWER_WORKER_PROFILE } from './tower'; +import TOWER_WORKER_ROLE_OVERLAY from './tower-worker-overlay.md?raw'; + +const TOWER_WORKER_TOOLS = [ + 'Agent', + 'Bash', + 'TowerFinding', + 'TowerInbox', + 'TowerMission', + 'TowerReview', + 'TowerSend', + 'TowerStatus', + 'CronCreate', + 'CronDelete', + 'CronList', + 'Edit', + 'EnterPlanMode', + 'ExitPlanMode', + 'Glob', + 'Grep', + 'Read', + 'ReadMediaFile', + 'Skill', + 'TaskList', + 'TaskOutput', + 'TaskStop', + 'TodoList', + 'WebSearch', + 'FetchURL', + 'Write', + 'mcp__*', +] as const; + +// Mirrors the coder role in `session/agentLifecycle/profile/profiles.ts` — +// the tower-worker role is that handoff contract plus the tower overlay. +const CODER_ROLE = + `${TASK_AGENT_ROLE_PREFIX}\n\n` + + 'Your final message is the entire handoff — the parent sees nothing else from your run. ' + + 'Make it technically complete: what you changed and why, the path of every file you touched, ' + + 'how you verified the change (tests or commands run, with results), and anything left undone ' + + 'or worth follow-up. A final message of only a sentence or two is treated as too brief and ' + + 'sent back to you for expansion, costing an extra turn.'; + +const TOWER_WORKER_ROLE = `${CODER_ROLE}\n\n${TOWER_WORKER_ROLE_OVERLAY.trim()}`; + +const DEFAULT_SUMMARY_POLICY = { + minChars: 200, + continuationPrompt: SUMMARY_CONTINUATION_PROMPT, + retries: 1, +} as const; + +export const TOWER_WORKER_PROFILE_DEF: AgentProfile = normalizeAgentProfile({ + name: TOWER_WORKER_PROFILE, + description: + 'Tower worker/reviewer agent — executes one tower mission in its own git worktree (or reviews one branch), coordinating only through Tower* tools. Spawned via the TowerSpawn tool.', + whenToUse: + 'Use this agent for non-trivial software engineering work that may require reading files, editing code, running commands, and returning a compact but technically complete summary to the parent agent.', + tools: TOWER_WORKER_TOOLS, + subagents: ['explore', 'plan'], + renderSystemPrompt: (context) => + renderSystemPromptResult(TOWER_WORKER_ROLE, context, { + skillActive: skillActiveFor(TOWER_WORKER_TOOLS), + }), + summaryPolicy: DEFAULT_SUMMARY_POLICY, +}); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 942cc1f832..cd72800011 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -325,6 +325,23 @@ import '#/agent/goal/goalDeadlineSchedulerService'; export * from '#/agent/goal/goal'; export * from '#/agent/goal/goalService'; export * from '#/agent/goal/types'; +export * from '#/features/tower/tower'; +export * from '#/features/tower/towerService'; +export * from '#/features/tower/towerRateLimit'; +export * from '#/features/tower/towerRateLimitService'; +export * from '#/features/tower/tools/init/init'; +export * from '#/features/tower/tools/plan/plan'; +export * from '#/features/tower/tools/spawn/spawn'; +export * from '#/features/tower/tools/merge/merge'; +export * from '#/features/tower/tools/teardown/teardown'; +export * from '#/features/tower/tools/send/send'; +export * from '#/features/tower/tools/inbox/inbox'; +export * from '#/features/tower/tools/finding/finding'; +export * from '#/features/tower/tools/review/review'; +export * from '#/features/tower/tools/mission/mission'; +export * from '#/features/tower/tools/status/status'; +export * from '#/features/tower/skill/skill'; +import '#/features/tower/towerFeature'; export * from '#/agent/usage/usage'; export * from '#/agent/usage/usageService'; export * from '#/agent/toolDedupe/toolDedupe'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 4ccbbdd6f5..8b5f59d9ec 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -10,7 +10,10 @@ * envelope while non-empty unversioned logs are rejected. Removal awaits the * agent task manager's graceful exit policy before draining turns and full * compaction, then disposing the child scope. Fans session-level - * permission-mode switches out to every live agent. Bound at Session scope. + * permission-mode switches out to every live agent — except + * `tower-worker`-profile agents, which TowerSpawn pins to `auto` (they run + * detached and unattended); the broadcast leaves them on `auto`. Bound at + * Session scope. * * No agent id is special here: the main agent is simply the agent created * with the conventional `MAIN_AGENT_ID`, and `fork` requires its source to @@ -37,6 +40,8 @@ import { IEventBus } from '#/app/event/eventBus'; import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection'; import { PermissionModeConfiguredModel } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; +import { ProfileModel } from '#/agent/profile/profileOps'; +import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -247,6 +252,15 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle broadcastPermissionMode(mode: PermissionMode): void { for (const handle of this.handles.values()) { + // Tower workers/reviewers stay pinned to auto (see the file header) — + // the profile name is read off the wire model, not the profile service, + // so the broadcast never has to materialize one. + if ( + handle.accessor.get(IWireService).getModel(ProfileModel).profileName === + TOWER_WORKER_PROFILE + ) { + continue; + } handle.accessor.get(IAgentPermissionModeService).setMode(mode); } } diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 5da3a73a52..29ceeb5a2a 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -45,6 +45,9 @@ const AGENT_TOOLS = [ 'GetGoal', 'SetGoalBudget', 'UpdateGoal', + // TowerInit stays in the default allowlist so the main agent can enter + // tower mode; the rest of the Tower* set is activated by TowerInit. + 'TowerInit', 'mcp__*', ] as const; diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 2aab73c8c7..f3e1b42027 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -303,7 +303,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 3_294, + tokens_before: 3_468, tokens_after: expect.any(Number), duration_ms: expect.any(Number), compacted_count: 6, @@ -582,7 +582,7 @@ describe('FullCompaction', () => { session_id: 'test-session', cwd: dir, trigger: 'auto', - token_count: 3_294, + token_count: 3_468, }); expect(post).toMatchObject({ hook_event_name: 'PostCompact', @@ -668,7 +668,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'manual', - tokens_before: 14_360, + tokens_before: 17_207, retry_count: 1, trace_id: 'trace-compact-1', }), @@ -1051,7 +1051,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 14_360, + tokens_before: 17_207, duration_ms: expect.any(Number), round: 1, retry_count: 0, @@ -1276,7 +1276,7 @@ describe('FullCompaction', () => { event: 'compaction_failed', properties: expect.objectContaining({ source: 'manual', - tokens_before: 14_360, + tokens_before: 17_207, duration_ms: expect.any(Number), retry_count: 4, error_type: 'APIConnectionError', @@ -1474,9 +1474,9 @@ describe('FullCompaction', () => { it('auto-compacts very large context in one full-history round when the summarizer accepts it', async () => { // The window must stay above the harness's fixed request overhead - // (system prompt + tools, ~14k): the post-compaction size is reported on + // (system prompt + tools, ~17k): the post-compaction size is reported on // the full-request basis, so a smaller window could never be satisfied. - const maxContextTokens = 20_000; + const maxContextTokens = 22_000; const ctx = testAgent(); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1652,12 +1652,12 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - tokens_before: 3_301, - // 3255 estimated request-overhead tokens (system prompt + tools) + + tokens_before: 3_475, + // 3429 estimated request-overhead tokens (system prompt + tools) + // 9 measured summary output tokens (scripted compaction exchange) + // 21 estimated tokens for the kept user messages — the summary // component is the REAL provider count, not a text estimate. - tokens_after: 3_285, + tokens_after: 3_459, compacted_count: 7, retry_count: 0, }), diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 888ae1a8b9..ffd7fde436 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -123,8 +123,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "