From 6d460e65e808496410e2edaa78cda4ceae85b05d Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 14 Jul 2026 15:54:29 +0530 Subject: [PATCH] feat(delegation): callee agents poke the caller back on "really done" + goal on any agent call (v0.190.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent→agent delegation was fundamentally synchronous: a caller either blocked on task_wait/ask_agent or fired-and-forgot and never heard back. This adds an async poke-back and a shared "goal" vocabulary across both delegation surfaces. Poke-back (opt-in, task path): task_create({ assignee:"agent:", poke_on_done:true }) stamps the caller's agent id + pinned claude transcript on the task (new nullable columns caller_agent/caller_claude_id/poke_on_done). When the delegate closes the loop (status → done or blocked), the task notifier resumes the caller's OWN transcript with the outcome via the new Automations.pokeCaller — an immediate --resume (not the 1-min-floored schedule()), guarded so it never spawns a competing run on a still-live caller. Provenance poke: (console badge "Poke · …"), audited agent.poked. The async counterpart to wait; implies autoDispatch. Goal on any agent call: task_create/task_dispatch take `goal` as the ergonomic synonym for `criteria` (the /goal convergence objective), and ask_agent gains the same optional `goal` so a taskless synchronous consult can hand the delegate an objective to converge on before answering — one vocabulary whether or not a task mediates. Typecheck clean, governance 68/68, store round-trip smoke test passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++++++ docs/agent-mcp-tools.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- src/edge/automations.ts | 30 ++++++++++++++++++++++++++++++ src/memory/memory-mcp.ts | 21 ++++++++++++++++----- src/server.ts | 10 +++++++++- src/state/db.ts | 9 +++++++++ src/state/tasks.ts | 9 +++++++-- src/tenant-registry.ts | 31 ++++++++++++++++++++++++++++++- src/terminal.ts | 23 +++++++++++++++-------- src/types.ts | 6 ++++++ 12 files changed, 147 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cc765..fc4afc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ new version heading in the same commit. ## [Unreleased] +## [0.190.0] — 2026-07-14 +### Added +- **Callee agents can poke the caller back when they're really done — async delegation, no polling.** A + delegate now closes an agent→agent hand-off by RESUMING the caller's own transcript with the outcome, + so a fire-and-forget delegation wakes the caller instead of making it block on `task_wait`. Opt in with + `task_create({ assignee:"agent:", goal:"…", poke_on_done:true })`: the caller stamps its agent id + + pinned claude transcript on the task (`caller_agent`/`caller_claude_id`), ends its turn, and is + `--resume`d the moment the delegate marks the task **done** (`✅ Really done: …`) or hands it back + **blocked** (`⛔ Handed back: …`). The wake fires immediately via `Automations.pokeCaller` (not the + 1-min-floored `schedule()`), is guarded so it never spawns a competing run on a still-live caller, gets + provenance `poke:` (console badge "Poke · …"), and is audited `agent.poked`. The async counterpart + to `wait` (which blocks). `poke_on_done` implies `autoDispatch`. +- **State a GOAL when delegating to an agent — with or without a task in between.** `task_create` / + `task_dispatch` take `goal` as the ergonomic synonym for `criteria` (the single-line objective that runs + a headless delegate under a `/goal` convergence condition until it holds). `ask_agent` gains the same + optional `goal`, so a taskless synchronous consult can hand the delegate an objective it works to before + answering — one vocabulary across both delegation surfaces. + (`src/state/{db,tasks}.ts`, `src/types.ts`, `src/edge/automations.ts`, `src/terminal.ts`, + `src/server.ts`, `src/tenant-registry.ts`, `src/memory/memory-mcp.ts`, `docs/agent-mcp-tools.md`.) + ## [0.189.0] — 2026-07-14 ### Fixed - **One-click "Create GitHub App" now actually works — the manifest was invalid.** Every attempt failed on diff --git a/docs/agent-mcp-tools.md b/docs/agent-mcp-tools.md index 91652b6..5f27741 100644 --- a/docs/agent-mcp-tools.md +++ b/docs/agent-mcp-tools.md @@ -19,7 +19,7 @@ can only ever act as its own session; the namespace/tenant/policy are enforced s | `kb_history` | `GET /api/kb/history` | `KbStore.history` | R | newest-first revisions | | `kb_revert` | `POST /api/kb/revert` | `KbStore.revert` | W | itself a new revision; audited `kb.reverted` | | `ask_human` | `POST /api/ask` + poll | questions | W (blocking) | blocks ~1h polling for the human answer; DMs the addressee out-of-band (`question.notified`) + mirrors to the chat thread so it isn't missed. The human answers **either from the web console Inbox** (`POST /api/questions/:id/answer`, cookie-gated) **or by REPLYING to the Slack/Discord DM** — the notifier binds the question to that DM (`question_dms`), and an inbound reply from that user is matched to the newest pending bound question and recorded as the answer (`answerQuestionFromChat`, attributed to their member email, `canViewQuestion`-gated; audited `question.answered.viaDm`), then acked in-thread. A DM that isn't answering a pending question falls through to the normal chat router. Default addressee = the run's operator (`sessionOwner`); optional `to` (name/email) routes the question to a SPECIFIC other member instead (card + DM target them, and `canViewQuestion` grants them the answer) — the "ask a teammate for info / a confirmation" channel. (Renamed from `ask`; `ask` still accepted as a hidden alias.) | -| `ask_agent` | `POST /api/ask-agent` + poll | `TerminalManager.askAgent`/`agentAskStatus` + `agent_asks` | W (blocking) | **synchronous agent→agent Q&A**, the machine sibling of `ask_human`: spawns the target agent as a one-off HEADLESS delegate (provenance `ask:`, run-as passthrough, every effect still gated) primed with the question, then long-polls `GET /api/ask-agent/:id` until it answers. Returns the answer inline. NO task row / board / inbox surface — an ephemeral request/response. Self-heals: a delegate that dies without answering (past a grace) → `failed` so the caller unblocks. Same wait envelope as `task_wait` (`AOS_TASK_WAIT_S`, max 6h); audited `agent.asked` | +| `ask_agent` | `POST /api/ask-agent` + poll | `TerminalManager.askAgent`/`agentAskStatus` + `agent_asks` | W (blocking) | **synchronous agent→agent Q&A**, the machine sibling of `ask_human`: spawns the target agent as a one-off HEADLESS delegate (provenance `ask:`, run-as passthrough, every effect still gated) primed with the question, then long-polls `GET /api/ask-agent/:id` until it answers. Returns the answer inline. Optional single-line `goal` opens the delegate under a `/goal` convergence condition (it works to the objective before answering — the taskless "delegate WITH a goal" path). NO task row / board / inbox surface — an ephemeral request/response. Self-heals: a delegate that dies without answering (past a grace) → `failed` so the caller unblocks. Same wait envelope as `task_wait` (`AOS_TASK_WAIT_S`, max 6h); audited `agent.asked` | | `answer` | `POST /api/agent/answer` | `TerminalManager.answerAgentAsk` + `agent_asks` | W | delegate-only (exposed when `ASK_ANSWER=1`, keyed on the `ask:` provenance): returns the delegate's result to the agent that asked and ends its run. The server resolves WHICH ask from the delegate's session, so it can't spoof the target; audited `agent.answered` | | `check_inbox` | `GET /api/inbox` | `TerminalManager.sessionInbox` | R | non-blocking pull of this session's feed | | `report` | `POST /api/report` | messages | W | `outcome` enum | @@ -37,7 +37,7 @@ can only ever act as its own session; the namespace/tenant/policy are enforced s | `list_capabilities` | `GET /api/agent/policy` | policy preview | R | | | `policy_check` | `POST /api/agent/policy/check` | policy preview | R | dry-run, no side effects | | `directory_lookup` | `GET /api/agent/directory` | `TeamStore` | R | people + their chat identities | -| `task_create` | `POST /api/tasks/create` | `TaskStore.create` | W | files a unit of work; author `agent:`; owner = run-as (delegation passthrough); `mode` headless/interactive for the dispatched run; optional `due` (ISO date) soft deadline; optional `goalId` (link to a strategic goal; a sub-task inherits its parent's `goalId` when omitted) + single-line `criteria` (drives a headless dispatch under a `/goal` convergence condition — Slice 2) + `dependsOn` (task ids this is blocked by — won't dispatch until they're done; cycle/self/missing rejected) | +| `task_create` | `POST /api/tasks/create` | `TaskStore.create` | W | files a unit of work; author `agent:`; owner = run-as (delegation passthrough); `mode` headless/interactive for the dispatched run; optional `due` (ISO date) soft deadline; optional `goalId` (link to a strategic goal; a sub-task inherits its parent's `goalId` when omitted) + single-line `goal`/`criteria` (synonyms — the objective that drives a headless dispatch under a `/goal` convergence condition — Slice 2) + `dependsOn` (task ids this is blocked by — won't dispatch until they're done; cycle/self/missing rejected). **Async poke-back**: `poke_on_done:true` (agent→agent hand-off only) stamps the caller's agent id + pinned claude transcript on the task (`caller_agent`/`caller_claude_id`); on the delegate closing the loop (done/blocked) the task notifier `--resume`s the caller's transcript with the outcome (`Automations.pokeCaller`, provenance `poke:`, guarded against a still-live caller; audited `agent.poked`) — the fire-and-forget counterpart to `wait` (which blocks). Implies `autoDispatch` | | `task_list` | `GET /api/tasks/list` | `TaskStore.list` | R | `assignee:"me"` → self; board query/FTS | | `task_get` | `GET /api/tasks/get` | `TaskStore.withEvents` | R | task + full activity timeline | | `task_claim` | `POST /api/tasks/claim` | `TaskStore.claim` | W | atomic take (→ doing); loses if already claimed | diff --git a/package-lock.json b/package-lock.json index c77db41..6cc508d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.189.0", + "version": "0.190.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.189.0", + "version": "0.190.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 7d26fa1..60cf474 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.189.0", + "version": "0.190.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/edge/automations.ts b/src/edge/automations.ts index 79ea455..e238a53 100644 --- a/src/edge/automations.ts +++ b/src/edge/automations.ts @@ -413,6 +413,36 @@ export class Automations { return this.remove(id); } + /** + * Wake a CALLER agent by resuming its transcript with a completion poke — the async "really done" signal + * a delegate sends back when it finishes a `poke_on_done` hand-off. Fires IMMEDIATELY (unlike schedule(), + * whose 1-min floor makes it a scheduler, not a wake): `--resume`s `callerClaudeId` with `message` as the + * next turn, so the caller continues its OWN plan with full context. Guarded — if that transcript already + * has a live session, the running caller will observe the outcome itself, so we skip rather than spawn a + * competing run on one conversation (same concern the chat thread-continuity path handles). The delegate + * (task assignee) is always the actor, never the caller, so this can't self-wake. Audited `agent.poked`. + */ + pokeCaller(input: { callerAgent: string; callerClaudeId: string; runAs?: string; message: string; source: string }): FireResult { + const agentId = input.callerAgent.startsWith('agent:') ? input.callerAgent.slice('agent:'.length) : input.callerAgent; + if (!this.os.agents.has(agentId)) return { ok: false, reason: `unknown caller agent: ${agentId}` }; + if (!input.callerClaudeId) return { ok: false, reason: 'no caller transcript to resume' }; + const live = this.db + .prepare("SELECT id FROM term_sessions WHERE claude_session_id = ? AND status = 'running'") + .all<{ id: string }>(input.callerClaudeId) + .some((r) => this.tm.isAlive(r.id)); + if (live) return { ok: false, reason: 'caller session still live — it will see the result itself' }; + const s = this.tm.createSession(agentId, `Poke ← ${input.source}`, input.message, `poke:${input.source}`, true, undefined, undefined, input.runAs, input.callerClaudeId); + this.os.audit.append({ + ts: Date.now(), + runId: s.id, + tenant: this.os.tenant, + principal: input.runAs ? `member:${input.runAs}` : 'system', + type: 'agent.poked', + data: { caller: agentId, source: input.source, runAs: input.runAs ?? null }, + }); + return { ok: true, sessionId: s.id, tmux: s.tmux }; + } + update(id: string, patch: { name?: string; mode?: ExecMode; schedule?: string; filter?: string; task?: string; enabled?: boolean }): Automation | undefined { const a = this.get(id); if (!a) return undefined; diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index a102018..0ed5cb5 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -682,6 +682,7 @@ const TOOLS = [ properties: { agent: { type: 'string', description: "The id of the agent to ask (from list_agents), e.g. \"researcher\"." }, question: { type: 'string', description: 'A specific, self-contained question or task — everything the other agent needs to answer without more context.' }, + goal: { type: 'string', description: 'Optional single-line objective / definition of done. When set, the other agent works under it as a `/goal` and converges autonomously until it holds before answering — delegate WITH a goal, no task required.' }, timeoutSeconds: { type: 'number', description: 'Optional. How long to block before parking (default ~15min interactive / ~15min unattended, max 6h). On timeout the other agent keeps working; ask again to keep waiting.' }, }, required: ['agent', 'question'], @@ -789,7 +790,9 @@ const TOOLS = [ labels: { type: 'array', items: { type: 'string' }, description: 'Optional freeform labels.' }, parentId: { type: 'string', description: 'Parent task id, to file this as a sub-task.' }, goalId: { type: 'string', description: 'Link this task to a strategic goal it advances (see goal_list for ids). Its progress then counts toward that goal.' }, - criteria: { type: 'string', description: 'A single-line, transcript-verifiable acceptance condition, e.g. "all tests in test/auth pass". When set on a headless auto-dispatched task, the worker runs under this as a `/goal` and converges autonomously until it holds.' }, + goal: { type: 'string', description: 'The single-line objective the delegate must achieve — the definition of done. On a headless auto-dispatched task the worker runs under this as a `/goal` and converges autonomously until it holds (alias for `criteria`). This is what to state when you delegate WITH a goal.' }, + criteria: { type: 'string', description: 'A single-line, transcript-verifiable acceptance condition, e.g. "all tests in test/auth pass". When set on a headless auto-dispatched task, the worker runs under this as a `/goal` and converges autonomously until it holds. Synonym of `goal`.' }, + poke_on_done: { type: 'boolean', description: 'Fire-and-forget delegation with an async wake-up: hand off, end your turn, and be RESUMED automatically when the delegate finishes (or blocks) — no polling. The async counterpart to `wait` (which blocks). Only for an agent-assigned auto-dispatched task. Default false.' }, dependsOn: { type: 'array', items: { type: 'string' }, description: 'Task ids this task is BLOCKED BY — it will not dispatch until they are all done. To encode a pipeline: file the earlier steps first, capture their ids from the results, and pass them here so this step waits for them.' }, autoDispatch: { type: 'boolean', description: 'If true and assigned to an agent, the board auto-spawns a session to work it. Default false.' }, mode: { type: 'string', enum: ['headless', 'interactive'], description: 'How a dispatched session runs: "headless" (default — works to completion then exits) or "interactive" (an attachable TUI a human drives).' }, @@ -1200,7 +1203,7 @@ async function askAgent(args: Record): Promise { const res = await fetch(AOS_URL + '/api/ask-agent', { method: 'POST', headers: H({ 'content-type': 'application/json' }), - body: JSON.stringify({ session: SESSION, agent, question }), + body: JSON.stringify({ session: SESSION, agent, question, goal: args.goal !== undefined ? String(args.goal) : undefined }), }); const posted = (await res.json()) as { id?: string; error?: string }; if (posted.error) return `Could not ask ${agent}: ${posted.error}`; @@ -1811,10 +1814,13 @@ async function taskCreate(args: Record): Promise { labels: Array.isArray(args.labels) ? args.labels.map(String) : undefined, parentId: args.parentId !== undefined ? String(args.parentId) : undefined, goalId: args.goalId !== undefined ? String(args.goalId) : undefined, - criteria: args.criteria !== undefined ? String(args.criteria) : undefined, + // `goal` is the ergonomic alias for `criteria` (the /goal convergence condition); either sets it. + criteria: args.criteria !== undefined ? String(args.criteria) : (args.goal !== undefined ? String(args.goal) : undefined), dependsOn: Array.isArray(args.dependsOn) ? args.dependsOn.map(String) : undefined, - // `wait` implies autoDispatch — you can't block on work that never starts. - autoDispatch: args.autoDispatch === true || args.wait === true, + // `wait` (block) and `poke_on_done` (async wake) both imply autoDispatch — you can't await/be-woken + // by work that never starts. + autoDispatch: args.autoDispatch === true || args.wait === true || args.poke_on_done === true, + pokeOnDone: args.poke_on_done === true, mode: args.mode === 'interactive' ? 'interactive' : undefined, dueAt: parseDue(args.due), }), @@ -1827,6 +1833,11 @@ async function taskCreate(args: Record): Promise { const outcome = await taskWait({ id: d.id, timeoutSeconds: args.timeoutSeconds }); return `Filed task ${d.id}: "${title}"${who}.\n${outcome}`; } + // poke_on_done → fire-and-forget async: don't poll or wait. End your turn; you'll be resumed with the + // result the moment the delegate finishes (or blocks). + if (args.poke_on_done === true) { + return `Filed task ${d.id}: "${title}"${who}. You'll be woken with the result when it finishes — you can end your turn now; no need to poll.`; + } return `Filed task ${d.id}: "${title}"${who}. Track it with task_get "${d.id}".`; } diff --git a/src/server.ts b/src/server.ts index 9bcbcab..7a007ae 100644 --- a/src/server.ts +++ b/src/server.ts @@ -892,7 +892,8 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const target = String(b.agent || '').trim(); const question = String(b.question || '').trim(); if (!question) return sendJson(res, 400, { error: 'question is required' }); - const out = tm.askAgent(session, agent, target, question); + const goal = typeof b.goal === 'string' && b.goal.trim() ? b.goal.trim() : undefined; + const out = tm.askAgent(session, agent, target, question, goal); return sendJson(res, out.error ? 400 : 200, out); } const askAgentMatch = p.match(/^\/api\/ask-agent\/([\w-]+)$/); @@ -1290,6 +1291,10 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: return sendJson(res, 200, { ok: false, error: `no agent "${targetId}" — assign to one of: ${valid} (call list_agents to see the roster)` }); } } + // Poke-back: an agent delegating to ANOTHER agent can ask to be woken when the delegate finishes. + // We stamp the caller's agent id + pinned claude transcript so the task notifier can `--resume` this + // session on done/blocked. Only meaningful for an agent→agent hand-off (a poke has a caller to wake). + const pokeOnDone = (b.pokeOnDone === true || b.pokeOnDone === 'true') && !!assignee && assignee.startsWith('agent:'); try { // owner defaults to the creating session's run-as member — HUMAN PASSTHROUGH: a task filed by an // agent acting as Alice dispatches (later) as Alice too, so accountability ladders to the person. @@ -1305,6 +1310,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: goalId: typeof b.goalId === 'string' && b.goalId ? b.goalId : undefined, criteria: typeof b.criteria === 'string' && b.criteria ? b.criteria : undefined, dependsOn: Array.isArray(b.dependsOn) ? b.dependsOn.map(String) : undefined, + callerAgent: pokeOnDone ? `agent:${agent}` : undefined, + callerClaudeId: pokeOnDone ? tm.sessionClaudeId(session) : undefined, + pokeOnDone: pokeOnDone || undefined, dueAt: typeof b.dueAt === 'number' && Number.isFinite(b.dueAt) ? b.dueAt : undefined, createdBy: `agent:${agent}`, }); diff --git a/src/state/db.ts b/src/state/db.ts index d7cbfdc..cb735e3 100644 --- a/src/state/db.ts +++ b/src/state/db.ts @@ -725,6 +725,15 @@ function migrate(db: Db): void { addColumn(db, 'tasks', 'goal_id', 'TEXT'); addColumn(db, 'tasks', 'criteria', 'TEXT'); + // Async poke-back: when one AGENT delegates a task to another (task_create with poke_on_done), we + // stamp the caller's agent id + its pinned claude transcript id here. On the delegate closing the loop + // (task_update → done/blocked), the notifier `--resume`s the caller's transcript with a "really done" + // poke, so a fire-and-forget hand-off wakes the caller instead of the caller having to poll. Nullable; + // a task with no caller (human-filed, or poke off) never pokes. See docs/tasks-plan.md. + addColumn(db, 'tasks', 'caller_agent', 'TEXT'); + addColumn(db, 'tasks', 'caller_claude_id', 'TEXT'); + addColumn(db, 'tasks', 'poke_on_done', 'INTEGER NOT NULL DEFAULT 0'); + // Human verdict on a finished run — the ground-truth signal for the agent maturity score (a person // who oversaw the run says it did / didn't do what they wanted). One verdict per session, latest wins. // Feeds src/state/agent-stats.ts as the HIGHEST-confidence outcome layer, above the agent's own diff --git a/src/state/tasks.ts b/src/state/tasks.ts index 6e5b629..af74468 100644 --- a/src/state/tasks.ts +++ b/src/state/tasks.ts @@ -26,6 +26,7 @@ interface TaskRow { id: string; tenant: string; title: string; body: string; status: string; priority: number; labels: string; assignee: string | null; owner: string | null; parent_id: string | null; mode: string; auto_dispatch: number; goal_id: string | null; criteria: string | null; + caller_agent: string | null; caller_claude_id: string | null; poke_on_done: number; due_at: number | null; attempts: number; last_session_id: string | null; created_by: string; created_at: number; updated_at: number; updated_by: string; rank?: number; @@ -91,12 +92,14 @@ export class TaskStore { this.db .prepare(`INSERT INTO tasks (id, tenant, title, body, status, priority, labels, assignee, owner, parent_id, mode, auto_dispatch, - goal_id, criteria, due_at, attempts, last_session_id, created_by, created_at, updated_at, updated_by) - VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?)`) + goal_id, criteria, caller_agent, caller_claude_id, poke_on_done, due_at, attempts, last_session_id, + created_by, created_at, updated_at, updated_by) + VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?)`) .run( id, input.tenant, input.title.trim() || 'Untitled task', input.body ?? '', priority, JSON.stringify(labels), input.assignee ?? null, input.owner ?? null, input.parentId ?? null, mode, input.autoDispatch ? 1 : 0, goalId, oneLine(input.criteria), + input.callerAgent ?? null, input.callerClaudeId ?? null, input.pokeOnDone ? 1 : 0, input.dueAt ?? null, input.createdBy, now, now, input.createdBy, ); if (goalId && this.db.prepare('SELECT 1 FROM goals WHERE id = ?').get(goalId)) { @@ -531,6 +534,8 @@ function toTask(r: TaskRow): Task { owner: r.owner ?? undefined, parentId: r.parent_id ?? undefined, mode: r.mode === 'interactive' ? 'interactive' : 'headless', autoDispatch: r.auto_dispatch === 1, goalId: r.goal_id ?? undefined, criteria: r.criteria ?? undefined, + callerAgent: r.caller_agent ?? undefined, callerClaudeId: r.caller_claude_id ?? undefined, + pokeOnDone: r.poke_on_done === 1, dueAt: r.due_at ?? undefined, attempts: r.attempts, lastSessionId: r.last_session_id ?? undefined, createdBy: r.created_by, createdAt: r.created_at, updatedAt: r.updated_at, updatedBy: r.updated_by, }; diff --git a/src/tenant-registry.ts b/src/tenant-registry.ts index ccda4da..117c389 100644 --- a/src/tenant-registry.ts +++ b/src/tenant-registry.ts @@ -211,7 +211,12 @@ export class TenantRegistry { // Task lifecycle → Inbox: a create/assign/status change lands an audience-addressed inbox card for // the right human (assignee/owner) — routed via resolveRecipients — and DMs them. Fires for EVERY // mutation path (console, agent MCP, dispatcher) because the sink lives on the store, not the routes. - os.tasks.setNotifier((notice) => { void notifyTaskEvent(os, tm, slack, discord, consoleOrigin, notice); }); + os.tasks.setNotifier((notice) => { + void notifyTaskEvent(os, tm, slack, discord, consoleOrigin, notice); + // Async poke-back: a delegate that closed a `poke_on_done` hand-off resumes the CALLER agent's + // transcript with the outcome, so a fire-and-forget delegation wakes the caller (no polling). + maybePokeCaller(autos, os, notice); + }); // Agent → teammate: when an agent uses the `notify` tool, the inbox card is written inline (addressed // to the target member); this sink DMs that member on their linked Slack/Discord too. tm.setMemberNotifier((notice) => { void notifyMember(os, slack, discord, notice); }); @@ -402,6 +407,30 @@ export async function notifyTaskEvent(os: AgentOS, tm: Pick` is unique per task, so bucketing // by it would leak a space per ownerless task. A task WITH an owner passes run_as here (→ the member). - if (!spawnedBy || spawnedBy.startsWith('automation:') || spawnedBy.startsWith('task:')) return 'automations'; + if (!spawnedBy || spawnedBy.startsWith('automation:') || spawnedBy.startsWith('task:') || spawnedBy.startsWith('poke:')) return 'automations'; return spawnedBy; } @@ -679,6 +679,8 @@ export class TerminalManager { if (spawnedBy.startsWith('task:')) return `Task · ${spawnedBy.slice('task:'.length)}${asSuffix}`; // One-off ask_agent delegate (`ask:`) — another agent asked this one a question and is waiting. if (spawnedBy.startsWith('ask:')) return `Ask · ${spawnedBy.slice('ask:'.length)}${asSuffix}`; + // Async poke-back (`poke:`) — this caller was resumed because a delegate it handed off finished. + if (spawnedBy.startsWith('poke:')) return `Poke · ${spawnedBy.slice('poke:'.length)}${asSuffix}`; const m = this.os.team.getMember(spawnedBy); return m ? m.name || m.email : spawnedBy; } @@ -2288,7 +2290,7 @@ export class TerminalManager { * {@link agentAskStatus}. An ephemeral request/response — no task row, no board/inbox surface. * Returns `{ error }` for an unknown/ineligible target. */ - askAgent(callerSession: string, callerAgent: string, targetAgent: string, question: string): { id?: string; error?: string } { + askAgent(callerSession: string, callerAgent: string, targetAgent: string, question: string, goal?: string): { id?: string; error?: string } { const target = (targetAgent || '').trim(); if (!target) return { error: 'which agent? (agent is required)' }; if (target === callerAgent) return { error: 'an agent cannot ask itself — pick a different teammate' }; @@ -2304,7 +2306,10 @@ export class TerminalManager { .run(id, this.os.tenant, callerSession, callerAgent, target, question, 'pending', runAs ?? null, Date.now()); // Provenance `ask:` makes the delegate an ask-answerer at launch (→ ASK_ANSWER exposes the // `answer` tool); run-as passes the accountable human through. Headless one-off — reaped at turn end. - const s = this.createSession(target, `Ask ← ${callerAgent}`, buildAskAgentPrompt(id, callerAgent, question), `ask:${callerAgent}`, true, undefined, undefined, runAs); + // A `goal` (when the installed claude supports `/goal`) opens the prompt under a convergence condition + // so the delegate works to the objective before answering — the taskless "delegate with a goal" path. + const goalMode = !!(goal && goal.trim()) && claudeSupportsGoal(); + const s = this.createSession(target, `Ask ← ${callerAgent}`, buildAskAgentPrompt(id, callerAgent, question, goalMode ? goal!.trim() : undefined), `ask:${callerAgent}`, true, undefined, undefined, runAs); this.db.prepare('UPDATE agent_asks SET delegate_run_id = ? WHERE id = ?').run(s.id, id); this.audit(callerSession, callerAgent, 'agent.asked', { askId: id, target, delegate: s.id, runAs: runAs ?? null }); return { id }; @@ -3693,15 +3698,17 @@ function titleFromSummary(summary: string): string { /** The prompt handed to an ask_agent delegate: answer the caller's question, then close the loop with * the `answer` tool. Everything the caller receives is the `answer` text — so it must be self-contained. */ -function buildAskAgentPrompt(id: string, callerAgent: string, question: string): string { - return ( +function buildAskAgentPrompt(id: string, callerAgent: string, question: string, goal?: string): string { + const base = `Another agent (${callerAgent}) needs your help and is WAITING on your answer.\n\n` + `Their question / request:\n${question}\n\n` + `Do whatever it takes to answer it — investigate, run tools, reason it through. When you have the ` + `answer, call answer({ answer: "" }). That returns it to ${callerAgent} and ends ` + `this run. Put EVERYTHING they need in that one call — they receive only the \`answer\` text, not the ` + - `rest of your output. If you genuinely cannot help, still call answer with a short explanation of why.` - ); + `rest of your output. If you genuinely cannot help, still call answer with a short explanation of why.`; + // A `goal` opens the run under a `/goal` convergence condition, so an independent evaluator drives the + // delegate across turns until the objective holds; it then returns via `answer` in that same turn. + return goal ? `/goal ${goal}\n\n${base}` : base; } function toSession(r: SessionRow): Session { diff --git a/src/types.ts b/src/types.ts index 0738ea9..ec49e32 100644 --- a/src/types.ts +++ b/src/types.ts @@ -673,6 +673,9 @@ export interface Task { goalId?: string; // the strategic Goal this task advances (Slice 2 linkage) criteria?: string; // single-line acceptance condition; drives a headless run under a `/goal` on dispatch dependsOn?: string[]; // task ids this task is blocked by — it won't dispatch until they're done/cancelled + callerAgent?: string; // 'agent:' that delegated this task and wants a poke-back on completion + callerClaudeId?: string; // the caller's pinned claude transcript id — resumed to deliver the poke + pokeOnDone?: boolean; // wake the caller (resume its transcript) when this task reaches done/blocked dueAt?: number; attempts: number; lastSessionId?: string; @@ -719,6 +722,9 @@ export interface TaskCreateInput { goalId?: string; // link to a strategic Goal (Slice 2) criteria?: string; // single-line acceptance condition → `/goal` convergence on a headless dispatch dependsOn?: string[]; // task ids this task is blocked by (won't dispatch until they finish) + callerAgent?: string; // 'agent:' delegating this task — poked back on completion (poke-on-done) + callerClaudeId?: string; // the caller's pinned claude transcript id (for the resume-poke) + pokeOnDone?: boolean; // resume the caller's transcript when this task reaches done/blocked dueAt?: number; createdBy: string; // member id | 'agent:' }