typeclaw 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -84
- package/package.json +1 -1
- package/src/agent/index.ts +80 -8
- package/src/agent/live-subagents.ts +215 -0
- package/src/agent/plugin-tools.ts +60 -20
- package/src/agent/session-origin.ts +15 -0
- package/src/agent/subagents.ts +140 -3
- package/src/agent/system-prompt.ts +42 -0
- package/src/agent/tools/channel-reply.ts +24 -1
- package/src/agent/tools/channel-send.ts +26 -1
- package/src/agent/tools/spawn-subagent.ts +283 -0
- package/src/agent/tools/subagent-cancel.ts +96 -0
- package/src/agent/tools/subagent-output.ts +192 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +26 -0
- package/src/bundled-plugins/explorer/explorer.ts +103 -0
- package/src/bundled-plugins/explorer/index.ts +11 -0
- package/src/bundled-plugins/guard/index.ts +12 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +139 -0
- package/src/bundled-plugins/guard/policy.ts +1 -0
- package/src/bundled-plugins/operator/index.ts +11 -0
- package/src/bundled-plugins/operator/operator.ts +76 -0
- package/src/bundled-plugins/scout/index.ts +11 -0
- package/src/bundled-plugins/scout/scout.ts +94 -0
- package/src/channels/router.ts +32 -0
- package/src/cli/init.ts +8 -1
- package/src/cli/oauth-callbacks.ts +64 -34
- package/src/cli/provider.ts +9 -4
- package/src/config/config.ts +73 -16
- package/src/config/index.ts +3 -0
- package/src/config/providers.ts +106 -0
- package/src/cron/index.ts +3 -0
- package/src/cron/schema.ts +20 -0
- package/src/init/dockerfile.ts +44 -5
- package/src/init/models-dev.ts +1 -0
- package/src/permissions/builtins.ts +23 -2
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/types.ts +15 -22
- package/src/run/bundled-plugins.ts +6 -0
- package/src/run/channel-session-factory.ts +19 -0
- package/src/run/index.ts +56 -6
- package/src/server/index.ts +103 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
- package/src/skills/typeclaw-config/SKILL.md +29 -26
- package/typeclaw.schema.json +12 -0
|
@@ -226,6 +226,21 @@ function renderChannelOrigin(
|
|
|
226
226
|
'reply, your entire final visible response must be exactly `NO_REPLY`.',
|
|
227
227
|
'Any other visible text without a channel tool call is blocked.',
|
|
228
228
|
'',
|
|
229
|
+
'**Default to ONE reply per inbound.** Send a second `channel_reply` only',
|
|
230
|
+
'when the user genuinely benefits from it:',
|
|
231
|
+
'',
|
|
232
|
+
'- the user asked multiple distinct things and each deserves its own',
|
|
233
|
+
' scoped answer,',
|
|
234
|
+
'- your reply exceeds the platform message limit and must be chunked,',
|
|
235
|
+
'- you need to post an attachment AND commentary on it on Discord (on',
|
|
236
|
+
' Slack, pass `text` and `attachments` in a single `channel_reply` call),',
|
|
237
|
+
'- you are emitting progress updates during a long-running task and the',
|
|
238
|
+
' channel would otherwise sit silent.',
|
|
239
|
+
'',
|
|
240
|
+
'Do NOT send a second reply just to rephrase, restate, summarize, or',
|
|
241
|
+
'"confirm in plain language" something you already said. After the first',
|
|
242
|
+
'reply lands, end your turn — the user will respond if they want more.',
|
|
243
|
+
'',
|
|
229
244
|
'To reply in this conversation, call `channel_reply({ text })`. Addressing',
|
|
230
245
|
`is filled in from this session, including the thread${origin.thread !== null ? '' : ' (none here — this is a channel-root session)'}, so you don't`,
|
|
231
246
|
'need to copy any of these fields:',
|
package/src/agent/subagents.ts
CHANGED
|
@@ -19,17 +19,40 @@ export type SubagentContext<P = unknown> = {
|
|
|
19
19
|
|
|
20
20
|
export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// Fields shared verbatim between the plugin-author-facing `Subagent` in
|
|
23
|
+
// `@/plugin/types` and the runtime-internal `Subagent` below. Every consumer
|
|
24
|
+
// that reads from `SubagentRegistry` (the spawn_subagent tool, payload
|
|
25
|
+
// validation, default session construction) only touches these fields, so
|
|
26
|
+
// keeping them in a single declaration makes the plugin→internal shim a
|
|
27
|
+
// rest-spread instead of a hand-maintained property list. Adding a new field
|
|
28
|
+
// here surfaces it on both types in one edit, which is the regression class
|
|
29
|
+
// the previous shim shape suffered: `visibility` and `requiresSpecificPermission`
|
|
30
|
+
// existed on the plugin type but were silently dropped by the shim, so every
|
|
31
|
+
// plugin-contributed public subagent appeared internal at the registry layer.
|
|
32
|
+
//
|
|
33
|
+
// The two fields that intentionally diverge — `tools` and `customTools` —
|
|
34
|
+
// live on each concrete `Subagent` type below. The plugin side uses
|
|
35
|
+
// `BuiltinToolRef[]` + `Tool<any>[]` (the public plugin API, decoupled from
|
|
36
|
+
// pi-coding-agent's internal tool shape); the internal side uses the resolved
|
|
37
|
+
// `AgentSessionTools` + `ToolDefinition[]` that pi-coding-agent actually
|
|
38
|
+
// consumes. The boundary is real and load-bearing — collapsing it would
|
|
39
|
+
// expose pi-coding-agent's internal API as part of the plugin contract.
|
|
40
|
+
export type SubagentShared<P = unknown> = {
|
|
23
41
|
systemPrompt: string
|
|
24
42
|
// Model profile this subagent prefers. Resolved against `config.models` at
|
|
25
43
|
// session construction. Unknown profile names fall back to `default` with
|
|
26
44
|
// a warning. See `Subagent` in `@/plugin/types` for the full contract.
|
|
27
45
|
profile?: string
|
|
28
|
-
tools?: AgentSessionTools
|
|
29
|
-
customTools?: ToolDefinition[]
|
|
30
46
|
payloadSchema?: z.ZodType<P>
|
|
31
47
|
handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
|
|
32
48
|
toolResultBudget?: ToolResultBudget
|
|
49
|
+
visibility?: 'public' | 'internal'
|
|
50
|
+
requiresSpecificPermission?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type Subagent<P = unknown> = SubagentShared<P> & {
|
|
54
|
+
tools?: AgentSessionTools
|
|
55
|
+
customTools?: ToolDefinition[]
|
|
33
56
|
}
|
|
34
57
|
|
|
35
58
|
export type SubagentRegistry = Readonly<Record<string, Subagent<any>>>
|
|
@@ -136,6 +159,17 @@ export type InvokeSubagentOptions = {
|
|
|
136
159
|
spawnedByRole?: string
|
|
137
160
|
spawnedByOrigin?: SessionOrigin
|
|
138
161
|
onProviderError?: (errorMessage: string) => void
|
|
162
|
+
// Fires synchronously after the AgentSession is created and before
|
|
163
|
+
// session.prompt() is invoked, with both the live session reference and
|
|
164
|
+
// its allocated sessionId. The only consumer in production is the spawn
|
|
165
|
+
// tool's LiveSubagentRegistry path, which uses it to attach a progress
|
|
166
|
+
// subscriber and register the abort handle while invokeSubagent retains
|
|
167
|
+
// its `Promise<void>` external contract.
|
|
168
|
+
onSessionCreated?: (event: {
|
|
169
|
+
session: AgentSession
|
|
170
|
+
sessionId: string | undefined
|
|
171
|
+
abort: () => Promise<void>
|
|
172
|
+
}) => void
|
|
139
173
|
}
|
|
140
174
|
|
|
141
175
|
export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
|
|
@@ -155,6 +189,15 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
155
189
|
const { session, dispose, hooks, sessionId, agentDir, origin, getTranscriptPath } = normalizeSubagentSession(
|
|
156
190
|
await createSessionForSubagent(subagent, sessionOptions),
|
|
157
191
|
)
|
|
192
|
+
if (options.onSessionCreated !== undefined) {
|
|
193
|
+
options.onSessionCreated({
|
|
194
|
+
session,
|
|
195
|
+
sessionId,
|
|
196
|
+
abort: async () => {
|
|
197
|
+
await session.abort()
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
}
|
|
158
201
|
const unsubProviderErrors =
|
|
159
202
|
options.onProviderError !== undefined
|
|
160
203
|
? subscribeProviderErrors(session, (err) => options.onProviderError!(err.message))
|
|
@@ -204,6 +247,100 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
204
247
|
}
|
|
205
248
|
}
|
|
206
249
|
|
|
250
|
+
export type SubagentHandle = {
|
|
251
|
+
taskId: string
|
|
252
|
+
sessionId: string | undefined
|
|
253
|
+
abort: () => Promise<void>
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export type StartSubagentResult = {
|
|
257
|
+
handle: Promise<SubagentHandle>
|
|
258
|
+
completion: Promise<{ ok: true; finalMessage?: string } | { ok: false; error: string }>
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export type StartSubagentOptions = InvokeSubagentOptions & {
|
|
262
|
+
taskId: string
|
|
263
|
+
onSession?: (event: { session: AgentSession; sessionId: string | undefined; abort: () => Promise<void> }) => void
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Non-blocking alternative to invokeSubagent. Returns immediately with two
|
|
267
|
+
// promises:
|
|
268
|
+
// - `handle` resolves with { taskId, sessionId, abort } once the AgentSession
|
|
269
|
+
// has been created (typically the first microtask). The taskId is what the
|
|
270
|
+
// caller chose; sessionId is allocated by the session factory.
|
|
271
|
+
// - `completion` resolves when the subagent's prompt finishes, ok=true with
|
|
272
|
+
// an optional final message, or ok=false with an error message.
|
|
273
|
+
// The two promises share a single underlying invokeSubagent invocation;
|
|
274
|
+
// `completion` settles after dispose, so the session reference exposed via
|
|
275
|
+
// `handle.abort` becomes a no-op once `completion` resolves.
|
|
276
|
+
export function startSubagent(name: string, options: StartSubagentOptions): StartSubagentResult {
|
|
277
|
+
let resolveHandle: (h: SubagentHandle) => void
|
|
278
|
+
let rejectHandle: (err: Error) => void
|
|
279
|
+
const handle = new Promise<SubagentHandle>((resolve, reject) => {
|
|
280
|
+
resolveHandle = resolve
|
|
281
|
+
rejectHandle = reject
|
|
282
|
+
})
|
|
283
|
+
let handleSettled = false
|
|
284
|
+
let finalMessage: string | undefined
|
|
285
|
+
|
|
286
|
+
const completion = invokeSubagent(name, {
|
|
287
|
+
...options,
|
|
288
|
+
onSessionCreated: (event) => {
|
|
289
|
+
handleSettled = true
|
|
290
|
+
resolveHandle({ taskId: options.taskId, sessionId: event.sessionId, abort: event.abort })
|
|
291
|
+
if (options.onSession !== undefined) {
|
|
292
|
+
options.onSession(event)
|
|
293
|
+
}
|
|
294
|
+
attachFinalMessageCapture(event.session, (msg) => {
|
|
295
|
+
finalMessage = msg
|
|
296
|
+
})
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
.then(() => ({ ok: true as const, ...(finalMessage !== undefined ? { finalMessage } : {}) }))
|
|
300
|
+
.catch((err: unknown) => {
|
|
301
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
302
|
+
if (!handleSettled) {
|
|
303
|
+
rejectHandle(err instanceof Error ? err : new Error(error))
|
|
304
|
+
}
|
|
305
|
+
return { ok: false as const, error }
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
return { handle, completion }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function attachFinalMessageCapture(session: AgentSession, onFinalMessage: (msg: string) => void): void {
|
|
312
|
+
try {
|
|
313
|
+
session.subscribe((event: unknown) => {
|
|
314
|
+
const ev = event as { type?: string; message?: { content?: unknown } }
|
|
315
|
+
if (ev?.type !== 'message_end') return
|
|
316
|
+
const text = extractFinalMessageText(ev.message?.content)
|
|
317
|
+
if (text !== null) onFinalMessage(text)
|
|
318
|
+
})
|
|
319
|
+
} catch {
|
|
320
|
+
// session.subscribe is a stable upstream API; defensive try is for test
|
|
321
|
+
// doubles that don't implement it.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function extractFinalMessageText(content: unknown): string | null {
|
|
326
|
+
if (typeof content === 'string') {
|
|
327
|
+
const trimmed = content.trim()
|
|
328
|
+
return trimmed ? trimmed : null
|
|
329
|
+
}
|
|
330
|
+
if (Array.isArray(content)) {
|
|
331
|
+
const parts: string[] = []
|
|
332
|
+
for (const part of content) {
|
|
333
|
+
if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
|
|
334
|
+
const text = (part as { text?: unknown }).text
|
|
335
|
+
if (typeof text === 'string') parts.push(text)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const joined = parts.join('').trim()
|
|
339
|
+
return joined ? joined : null
|
|
340
|
+
}
|
|
341
|
+
return null
|
|
342
|
+
}
|
|
343
|
+
|
|
207
344
|
export type SubagentConsumerLogger = {
|
|
208
345
|
info: (msg: string) => void
|
|
209
346
|
warn: (msg: string) => void
|
|
@@ -50,6 +50,48 @@ Your agent folder is a git repository.
|
|
|
50
50
|
- If a request is ambiguous in a way that doubles the effort, ask one clarifying question; otherwise proceed with a reasonable default.
|
|
51
51
|
- Never suppress errors to make things "work", and never fabricate results. Report failures clearly.
|
|
52
52
|
|
|
53
|
+
## Subagent orchestration
|
|
54
|
+
|
|
55
|
+
You can delegate focused work to subagents via three tools: \`spawn_subagent\`, \`subagent_output\`, \`subagent_cancel\`. Subagents run with their own context window and their own (often smaller, cheaper, or more constrained) tool set. The list of available subagents and what each one is for is rendered in the \`spawn_subagent\` tool description — re-read it before delegating.
|
|
56
|
+
|
|
57
|
+
There are two delegation modes. Pick deliberately.
|
|
58
|
+
|
|
59
|
+
**Mode A — Research fan-out** (in service of the current question)
|
|
60
|
+
|
|
61
|
+
When you need information to answer the user and the search is broad, fire 2-5 subagents in parallel with \`run_in_background: true\` covering different angles. End your response after spawning. The system will deliver a \`<system-reminder>\` for each completion; gather results then answer the user. Do NOT poll \`subagent_output\` in a tight loop.
|
|
62
|
+
|
|
63
|
+
The bundled \`explorer\` subagent is the right tool for **local** reconnaissance — anything reachable on the agent's filesystem: code, past sessions (\`sessions/*.jsonl\`), MEMORY.md and daily memory streams, skills, cron jobs, config, git history, mounts, channels state. It is read-only and runs on a fast/cheap model, so fire liberally. Do NOT ask it to plan, decide, or write code — it finds and reports.
|
|
64
|
+
|
|
65
|
+
The bundled \`scout\` subagent is its external counterpart — web research only. Use it when you need information from public sources (docs, library references, vendor changelogs, news, anything not already in this agent's folder). Scout runs \`websearch\` and \`webfetch\` in a fresh context window so the search churn does not pollute yours; it returns a citation-backed answer with a confidence rating. Prefer scout over running \`websearch\`/\`webfetch\` yourself when the research is non-trivial (more than 1-2 queries) or when you want to save your context for the synthesis step.
|
|
66
|
+
|
|
67
|
+
**Mode B — Delegate-and-converse** (the user asked you to DO something long-running)
|
|
68
|
+
|
|
69
|
+
When the user hands you a task that will take minutes (a multi-step browser session, a long build, a complex external operation), acknowledge in plain language ("Alright, running that in the background — I'll let you know when it's done"), spawn one subagent with \`run_in_background: true\`, then KEEP TALKING. Stay available for follow-ups, related questions, parallel small tasks. When the completion reminder lands, weave the result into your next reply naturally. If the conversation has gone idle, proactively message the user with the result rather than waiting.
|
|
70
|
+
|
|
71
|
+
Before you start an inline operation you expect to take more than ~30 seconds — a chain of \`webfetch\` calls, a \`websearch\` round you'll iterate on, a \`bash\` command that hits a slow API or scrapes a site, an \`agent-browser\` session, any "fetch N things in a loop" — pause and ask whether a subagent should run it instead. Inline long calls block the user from talking to you and pollute your context window with intermediate output; \`scout\` (for research) or \`operator\` (for actions with side effects) keeps the conversation responsive and returns a clean summary. The exception is a single quick call (one \`webfetch\` of a known URL, one \`websearch\` query you already know the shape of) — do those inline.
|
|
72
|
+
|
|
73
|
+
The bundled \`operator\` subagent is the right tool for this mode. It is write-capable (read, write, edit, bash with side effects) and runs on the default model. Use it for: browser sessions, multi-file refactors, deploys, batch API calls, anything that involves taking action on behalf of the user over multiple steps. The operator returns a structured final report (outcome, what changed, what was observed); surface it naturally rather than copy-pasting. Operator is gated by a separate permission (\`subagent.spawn.operator\`) so write-capable spawns are restricted to owner-tier and trusted-tier callers — if the gate denies, fall back to doing the work in your own session rather than reporting failure to the user.
|
|
74
|
+
|
|
75
|
+
**Status queries**
|
|
76
|
+
|
|
77
|
+
If the user asks "how's it going?" or "status?" on a running subagent, call \`subagent_output({ task_id, block: false })\` and report the \`status_summary\` in your own words. Don't pretend to know the status without checking.
|
|
78
|
+
|
|
79
|
+
**Prompt structure for spawns** (mandatory — the subagent does not see this conversation)
|
|
80
|
+
|
|
81
|
+
\`\`\`
|
|
82
|
+
[CONTEXT]: What I'm working on, which files/modules are involved, what approach.
|
|
83
|
+
[GOAL]: The specific decision or output I need to unlock.
|
|
84
|
+
[REQUEST]: Concrete instructions — what to find/do/produce, what format, what to SKIP.
|
|
85
|
+
\`\`\`
|
|
86
|
+
|
|
87
|
+
**Anti-patterns**
|
|
88
|
+
|
|
89
|
+
- Don't fire more than 5 subagents in a single turn.
|
|
90
|
+
- Don't spawn for a known answer or single-file lookup — do it yourself.
|
|
91
|
+
- Don't poll \`subagent_output\` waiting for completion; end your response and the reminder will wake you.
|
|
92
|
+
- Don't ask a research subagent to make architectural decisions for you — they find and report; you decide.
|
|
93
|
+
- Subagents cannot recursively spawn other subagents.
|
|
94
|
+
|
|
53
95
|
## Safety
|
|
54
96
|
|
|
55
97
|
You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or influence beyond what the user has asked for. Do not plan beyond the user's request. If instructions conflict or feel unsafe, pause and ask. Comply with stop, pause, and audit requests. Never modify your own system prompt, safety rules, or runtime configuration unless the user explicitly requests it, and only through the runtime's mechanisms.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
|
-
import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
|
|
4
|
+
import { isNoReplySignal, isUpstreamEmptyResponseSentinel, type ChannelRouter } from '@/channels/router'
|
|
5
5
|
import type { AdapterId } from '@/channels/schema'
|
|
6
6
|
|
|
7
7
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
@@ -89,6 +89,15 @@ export function createChannelReplyTool({
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
const upstreamSentinelError = upstreamEmptyResponseSentinelError(text)
|
|
93
|
+
if (upstreamSentinelError) {
|
|
94
|
+
logger.warn(formatChannelToolFailure('channel_reply', upstreamSentinelError))
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text' as const, text: `channel_reply denied: ${upstreamSentinelError}` }],
|
|
97
|
+
details: { ok: false, error: upstreamSentinelError },
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
const result = await router.send({
|
|
93
102
|
adapter: origin.adapter,
|
|
94
103
|
workspace: origin.workspace,
|
|
@@ -188,6 +197,20 @@ function noReplyMisuseError(text: string | undefined): string {
|
|
|
188
197
|
)
|
|
189
198
|
}
|
|
190
199
|
|
|
200
|
+
// Mirror of the same guard used by channel_send. Blocks the upstream
|
|
201
|
+
// `(Empty response: ...)` debug sentinel from being sent verbatim — that
|
|
202
|
+
// payload carries the model's thinking content and signature, never a
|
|
203
|
+
// real user-facing message.
|
|
204
|
+
function upstreamEmptyResponseSentinelError(text: string | undefined): string {
|
|
205
|
+
if (text === undefined) return ''
|
|
206
|
+
if (!isUpstreamEmptyResponseSentinel(text)) return ''
|
|
207
|
+
return (
|
|
208
|
+
'refusing to forward an upstream `(Empty response: ...)` sentinel; ' +
|
|
209
|
+
"that string is a provider-SDK debug dump containing the model's thinking content and signature, " +
|
|
210
|
+
'not a message body. End your turn silently (visible text empty or `NO_REPLY`) instead.'
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
191
214
|
// Mirror of the same hint used by channel_send. Kept identical so the model
|
|
192
215
|
// sees the same yield signal regardless of which tool it picked.
|
|
193
216
|
function consecutiveSendHint(countAfterSend: number): string {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
|
-
import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
|
|
4
|
+
import { isNoReplySignal, isUpstreamEmptyResponseSentinel, type ChannelRouter } from '@/channels/router'
|
|
5
5
|
import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
|
|
6
6
|
|
|
7
7
|
import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
|
|
@@ -112,6 +112,15 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
const upstreamSentinelError = upstreamEmptyResponseSentinelError(bodyText)
|
|
116
|
+
if (upstreamSentinelError) {
|
|
117
|
+
logger.warn(formatChannelToolFailure('channel_send', upstreamSentinelError))
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${upstreamSentinelError}` }],
|
|
120
|
+
details: { ok: false, error: upstreamSentinelError },
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
115
124
|
const result = await router.send({
|
|
116
125
|
adapter,
|
|
117
126
|
workspace: params.workspace,
|
|
@@ -208,6 +217,22 @@ function noReplyMisuseError(text: string | undefined): string {
|
|
|
208
217
|
)
|
|
209
218
|
}
|
|
210
219
|
|
|
220
|
+
// Defense-in-depth mirror of the recovery-path guard in router.ts. Blocks
|
|
221
|
+
// the upstream "(Empty response: {...})" sentinel from being sent verbatim
|
|
222
|
+
// as a channel message — the body of that sentinel carries the model's
|
|
223
|
+
// thinking content and Anthropic's tamper-proof signature, which must
|
|
224
|
+
// never reach a channel reader. Shape detection lives in
|
|
225
|
+
// `isUpstreamEmptyResponseSentinel` so all call sites stay in lockstep.
|
|
226
|
+
function upstreamEmptyResponseSentinelError(text: string | undefined): string {
|
|
227
|
+
if (text === undefined) return ''
|
|
228
|
+
if (!isUpstreamEmptyResponseSentinel(text)) return ''
|
|
229
|
+
return (
|
|
230
|
+
'refusing to forward an upstream `(Empty response: ...)` sentinel; ' +
|
|
231
|
+
"that string is a provider-SDK debug dump containing the model's thinking content and signature, " +
|
|
232
|
+
'not a message body. End your turn silently (visible text empty or `NO_REPLY`) instead.'
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
// Returns a behavioral hint to nudge the model toward yielding when it has
|
|
212
237
|
// been the only voice in the conversation for several messages. The router
|
|
213
238
|
// increments its counter AFTER router.send returns, so a count of 1 means
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
4
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
5
|
+
|
|
6
|
+
import type { PermissionService } from '@/permissions'
|
|
7
|
+
import type { Stream } from '@/stream'
|
|
8
|
+
|
|
9
|
+
import { type LiveSubagentRegistry, type SubagentCompletion } from '../live-subagents'
|
|
10
|
+
import type { SessionOrigin } from '../session-origin'
|
|
11
|
+
import { type CreateSessionForSubagent, type Subagent, type SubagentRegistry, startSubagent } from '../subagents'
|
|
12
|
+
|
|
13
|
+
export const SPAWN_TASK_ID_PREFIX = 'bg_'
|
|
14
|
+
|
|
15
|
+
export type SpawnSubagentToolDetails =
|
|
16
|
+
| {
|
|
17
|
+
ok: true
|
|
18
|
+
mode: 'sync'
|
|
19
|
+
subagent: string
|
|
20
|
+
taskId: string
|
|
21
|
+
sessionId: string | undefined
|
|
22
|
+
durationMs: number
|
|
23
|
+
finalMessage?: string
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
ok: true
|
|
27
|
+
mode: 'background'
|
|
28
|
+
subagent: string
|
|
29
|
+
taskId: string
|
|
30
|
+
sessionId: string | undefined
|
|
31
|
+
}
|
|
32
|
+
| { ok: false; error: string }
|
|
33
|
+
|
|
34
|
+
export type CreateSpawnSubagentToolOptions = {
|
|
35
|
+
registry: SubagentRegistry
|
|
36
|
+
liveRegistry: LiveSubagentRegistry
|
|
37
|
+
createSessionForSubagent: CreateSessionForSubagent
|
|
38
|
+
agentDir: string
|
|
39
|
+
parentSessionId: string
|
|
40
|
+
getOrigin: () => SessionOrigin | undefined
|
|
41
|
+
permissions?: PermissionService
|
|
42
|
+
stream?: Stream
|
|
43
|
+
generateTaskId?: () => string
|
|
44
|
+
now?: () => number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions) {
|
|
48
|
+
const {
|
|
49
|
+
registry,
|
|
50
|
+
liveRegistry,
|
|
51
|
+
createSessionForSubagent,
|
|
52
|
+
agentDir,
|
|
53
|
+
parentSessionId,
|
|
54
|
+
getOrigin,
|
|
55
|
+
permissions,
|
|
56
|
+
stream,
|
|
57
|
+
generateTaskId = () => `${SPAWN_TASK_ID_PREFIX}${randomUUID().replace(/-/g, '').slice(0, 12)}`,
|
|
58
|
+
now = () => Date.now(),
|
|
59
|
+
} = options
|
|
60
|
+
|
|
61
|
+
return defineTool({
|
|
62
|
+
name: 'spawn_subagent',
|
|
63
|
+
label: 'Spawn Subagent',
|
|
64
|
+
description: spawnSubagentDescription(registry),
|
|
65
|
+
parameters: Type.Object({
|
|
66
|
+
subagent_type: Type.String({
|
|
67
|
+
description:
|
|
68
|
+
'Name of the subagent to spawn. Must be a public subagent registered with this agent. See the system prompt section "Subagent orchestration" for the available list.',
|
|
69
|
+
}),
|
|
70
|
+
prompt: Type.String({
|
|
71
|
+
description:
|
|
72
|
+
'The full task description for the subagent. Use the [CONTEXT]/[GOAL]/[REQUEST] structure described in the system prompt. The subagent does not see the parent conversation; everything it needs must be in this string.',
|
|
73
|
+
}),
|
|
74
|
+
description: Type.Optional(
|
|
75
|
+
Type.String({
|
|
76
|
+
description: '3-5 word label for this spawn, used for logs and the status_summary. Optional.',
|
|
77
|
+
}),
|
|
78
|
+
),
|
|
79
|
+
run_in_background: Type.Optional(
|
|
80
|
+
Type.Boolean({
|
|
81
|
+
description:
|
|
82
|
+
'When true, the spawn returns immediately with a task_id; the subagent runs in the background and a system-reminder is delivered when it completes. ' +
|
|
83
|
+
'When false (default), the spawn blocks until the subagent finishes and returns its final message synchronously. ' +
|
|
84
|
+
'Use background mode for long-running tasks where you want to keep the conversation moving (Mode B) or for parallel fan-out (Mode A).',
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
async execute(_toolCallId, params): Promise<ToolReturn> {
|
|
90
|
+
const origin = getOrigin()
|
|
91
|
+
const subagent = lookupPublicSubagent(registry, params.subagent_type)
|
|
92
|
+
if (subagent === null) {
|
|
93
|
+
return errorResult(formatUnknownSubagentError(registry, params.subagent_type))
|
|
94
|
+
}
|
|
95
|
+
if (!hasPermissionForSubagent(permissions, origin, params.subagent_type, subagent)) {
|
|
96
|
+
return errorResult('subagent.spawn denied: insufficient permissions')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const taskId = generateTaskId()
|
|
100
|
+
const subagentName = params.subagent_type
|
|
101
|
+
const background = params.run_in_background === true
|
|
102
|
+
const payload: Record<string, unknown> = { requestId: taskId, prompt: params.prompt }
|
|
103
|
+
if (params.description !== undefined) payload.description = params.description
|
|
104
|
+
|
|
105
|
+
const startedAt = now()
|
|
106
|
+
const { handle, completion } = startSubagent(subagentName, {
|
|
107
|
+
registry,
|
|
108
|
+
createSessionForSubagent,
|
|
109
|
+
agentDir,
|
|
110
|
+
userPrompt: params.prompt,
|
|
111
|
+
payload: subagent.payloadSchema ? payload : undefined,
|
|
112
|
+
parentSessionId,
|
|
113
|
+
...(origin !== undefined ? { spawnedByOrigin: origin } : {}),
|
|
114
|
+
taskId,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
let resolvedHandle: { taskId: string; sessionId: string | undefined; abort: () => Promise<void> } | undefined
|
|
118
|
+
try {
|
|
119
|
+
resolvedHandle = await handle
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
122
|
+
return errorResult(`failed to spawn ${subagentName}: ${message}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const live = {
|
|
126
|
+
taskId,
|
|
127
|
+
sessionId: resolvedHandle.sessionId ?? '<pending>',
|
|
128
|
+
subagentName,
|
|
129
|
+
parentSessionId,
|
|
130
|
+
startedAt,
|
|
131
|
+
status: 'running' as const,
|
|
132
|
+
abort: resolvedHandle.abort,
|
|
133
|
+
awaitCompletion: () => completion.then((c) => completionToFinalShape(c, now() - startedAt)),
|
|
134
|
+
}
|
|
135
|
+
liveRegistry.register(live)
|
|
136
|
+
|
|
137
|
+
void completion.then((c) => {
|
|
138
|
+
const durationMs = now() - startedAt
|
|
139
|
+
liveRegistry.recordCompletion(taskId, completionToFinalShape(c, durationMs))
|
|
140
|
+
if (stream && background) {
|
|
141
|
+
stream.publish({
|
|
142
|
+
target: { kind: 'broadcast' },
|
|
143
|
+
payload: {
|
|
144
|
+
kind: 'subagent.completed',
|
|
145
|
+
taskId,
|
|
146
|
+
subagent: subagentName,
|
|
147
|
+
parentSessionId,
|
|
148
|
+
ok: c.ok,
|
|
149
|
+
durationMs,
|
|
150
|
+
...(c.ok ? {} : { error: c.error }),
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (background) {
|
|
157
|
+
const details: SpawnSubagentToolDetails = {
|
|
158
|
+
ok: true,
|
|
159
|
+
mode: 'background',
|
|
160
|
+
subagent: subagentName,
|
|
161
|
+
taskId,
|
|
162
|
+
sessionId: resolvedHandle.sessionId,
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
content: [
|
|
166
|
+
{
|
|
167
|
+
type: 'text' as const,
|
|
168
|
+
text: `Spawned ${subagentName} in background. task_id=${taskId}. You will receive a system-reminder when it completes. Use subagent_output to check progress or fetch results.`,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
details,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const result = await completion
|
|
176
|
+
const durationMs = now() - startedAt
|
|
177
|
+
if (!result.ok) {
|
|
178
|
+
const details: SpawnSubagentToolDetails = { ok: false, error: result.error }
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: 'text' as const, text: `${subagentName} failed after ${durationMs}ms: ${result.error}` }],
|
|
181
|
+
details,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const details: SpawnSubagentToolDetails = {
|
|
185
|
+
ok: true,
|
|
186
|
+
mode: 'sync',
|
|
187
|
+
subagent: subagentName,
|
|
188
|
+
taskId,
|
|
189
|
+
sessionId: resolvedHandle.sessionId,
|
|
190
|
+
durationMs,
|
|
191
|
+
...(result.finalMessage !== undefined ? { finalMessage: result.finalMessage } : {}),
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: 'text' as const,
|
|
197
|
+
text:
|
|
198
|
+
result.finalMessage !== undefined
|
|
199
|
+
? result.finalMessage
|
|
200
|
+
: `${subagentName} completed in ${durationMs}ms with no final message.`,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
details,
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function spawnSubagentDescription(registry: SubagentRegistry): string {
|
|
210
|
+
const publicNames = publicSubagentNames(registry)
|
|
211
|
+
const available = publicNames.length > 0 ? publicNames.join(', ') : '(none registered yet)'
|
|
212
|
+
return (
|
|
213
|
+
`Spawn a subagent to do focused work on your behalf. Use this when a task is heavy enough to deserve a fresh context window (research fan-out) ` +
|
|
214
|
+
`or long-running enough that you want to keep the conversation moving while it runs (delegate-and-converse). ` +
|
|
215
|
+
`Available subagents: ${available}. ` +
|
|
216
|
+
`When run_in_background=true (preferred for long-running work), the tool returns a task_id immediately and the subagent runs concurrently — ` +
|
|
217
|
+
`you will receive a system-reminder when it completes; do NOT poll subagent_output. ` +
|
|
218
|
+
`When run_in_background=false (default), the tool blocks and returns the subagent's final message synchronously. ` +
|
|
219
|
+
`Subagents cannot recursively spawn other subagents.`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function publicSubagentNames(registry: SubagentRegistry): string[] {
|
|
224
|
+
return Object.entries(registry)
|
|
225
|
+
.filter(([, sub]) => isPublicSubagent(sub))
|
|
226
|
+
.map(([name]) => name)
|
|
227
|
+
.sort()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isPublicSubagent(sub: Subagent<unknown>): boolean {
|
|
231
|
+
return sub.visibility === 'public'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function lookupPublicSubagent(registry: SubagentRegistry, name: string): Subagent<unknown> | null {
|
|
235
|
+
const sub = registry[name]
|
|
236
|
+
if (sub === undefined) return null
|
|
237
|
+
if (!isPublicSubagent(sub)) return null
|
|
238
|
+
return sub
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatUnknownSubagentError(registry: SubagentRegistry, requested: string): string {
|
|
242
|
+
const names = publicSubagentNames(registry)
|
|
243
|
+
const available = names.length > 0 ? names.join(', ') : '(none)'
|
|
244
|
+
return `Unknown subagent: ${requested}. Available: ${available}.`
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function hasPermissionForSubagent(
|
|
248
|
+
permissions: PermissionService | undefined,
|
|
249
|
+
origin: SessionOrigin | undefined,
|
|
250
|
+
subagentName: string,
|
|
251
|
+
subagent: Subagent<unknown>,
|
|
252
|
+
): boolean {
|
|
253
|
+
if (permissions === undefined) return true
|
|
254
|
+
const specific = `subagent.spawn.${subagentName}`
|
|
255
|
+
if (subagent.requiresSpecificPermission === true) {
|
|
256
|
+
return permissions.has(origin, specific)
|
|
257
|
+
}
|
|
258
|
+
if (permissions.has(origin, specific)) return true
|
|
259
|
+
return permissions.has(origin, 'subagent.spawn')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function completionToFinalShape(
|
|
263
|
+
c: { ok: true; finalMessage?: string } | { ok: false; error: string },
|
|
264
|
+
durationMs: number,
|
|
265
|
+
): SubagentCompletion {
|
|
266
|
+
if (c.ok) {
|
|
267
|
+
return { ok: true, durationMs, ...(c.finalMessage !== undefined ? { finalMessage: c.finalMessage } : {}) }
|
|
268
|
+
}
|
|
269
|
+
return { ok: false, error: c.error, durationMs }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
type ToolReturn = {
|
|
273
|
+
content: { type: 'text'; text: string }[]
|
|
274
|
+
details: SpawnSubagentToolDetails
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function errorResult(message: string): ToolReturn {
|
|
278
|
+
const details: SpawnSubagentToolDetails = { ok: false, error: message }
|
|
279
|
+
return {
|
|
280
|
+
content: [{ type: 'text', text: message }],
|
|
281
|
+
details,
|
|
282
|
+
}
|
|
283
|
+
}
|