@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.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/CHANGELOG.md +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
package/src/invocation-config.ts
CHANGED
|
@@ -1,37 +1,106 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { AgentConfig, IsolationMode, ThinkingLevel } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The model-facing `isolation` parameter.
|
|
6
|
+
*
|
|
7
|
+
* Shape matters more than wording here. As a single-value optional literal,
|
|
8
|
+
* models that fill every optional parameter had only `"worktree"` available to
|
|
9
|
+
* fill it with, and kept spawning worktrees across consecutive turns while their
|
|
10
|
+
* own reasoning said to omit the field. Every other optional parameter has an
|
|
11
|
+
* inert filler; this one did not. `"off"` is listed first and described as the
|
|
12
|
+
* default so the harmless value is the obvious one to reach for.
|
|
13
|
+
*
|
|
14
|
+
* The wording tracks Claude Code's own `isolation` parameter, whose phrasing
|
|
15
|
+
* models have the most exposure to: one description on the union rather than
|
|
16
|
+
* per-value ones, opening "Isolation mode.", then a sentence per value in
|
|
17
|
+
* schema order, each with its caveats in a trailing parenthetical. Two clauses
|
|
18
|
+
* are ours, because our shape is not theirs — `"off"` has no counterpart there
|
|
19
|
+
* (their enum is `worktree | remote`, so both of their values do something),
|
|
20
|
+
* and neither does the uncommitted-work warning, which is the specific trap of
|
|
21
|
+
* a fresh copy that cannot see the main checkout's pending work. Deliberately
|
|
22
|
+
* absent is any "only use a worktree when…" restriction: the tool states the
|
|
23
|
+
* capability and stops, and a second legal value is what lets a model decline
|
|
24
|
+
* one, not being told to.
|
|
25
|
+
*/
|
|
26
|
+
const isolationParamShape = {
|
|
27
|
+
isolation: Type.Optional(
|
|
28
|
+
Type.Union([Type.Literal("off"), Type.Literal("worktree")], {
|
|
29
|
+
description:
|
|
30
|
+
'Isolation mode. Default "off". "off" runs the agent in the current checkout, the same as omitting the field. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo (a copy cannot see uncommitted or staged changes in the main checkout).',
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the `isolation` parameter for a tool schema, or nothing when the
|
|
37
|
+
* project disabled worktrees (`worktreeIsolation: false`).
|
|
38
|
+
*
|
|
39
|
+
* Dropping the field beats accepting it and quietly downgrading. The setting is
|
|
40
|
+
* for a project whose model passes `"worktree"` on *every* call, so a
|
|
41
|
+
* per-result "isolation was disabled" note would be noise on every result and
|
|
42
|
+
* would keep raising the salience of a capability that isn't there. With no
|
|
43
|
+
* field there is nothing to pass, nothing to drop, and nothing to explain — the
|
|
44
|
+
* same trade `scheduleParam` makes for disabled scheduling, at zero LLM-context
|
|
45
|
+
* cost. The resolver gate and the `agent-manager` check still cover the paths a
|
|
46
|
+
* schema can't reach: agent files, the scheduler, and cross-extension RPC.
|
|
47
|
+
*
|
|
48
|
+
* Like `scheduleParam`, this is read once at tool registration — flipping the
|
|
49
|
+
* setting needs a new pi session for the schema to change.
|
|
50
|
+
*/
|
|
51
|
+
export function isolationParam(enabled: boolean): Partial<typeof isolationParamShape> {
|
|
52
|
+
return enabled ? isolationParamShape : {};
|
|
53
|
+
}
|
|
2
54
|
|
|
3
55
|
interface AgentInvocationParams {
|
|
4
|
-
thinking?: string;
|
|
5
56
|
max_turns?: number;
|
|
6
|
-
run_in_background?: boolean;
|
|
7
57
|
inherit_context?: boolean;
|
|
8
58
|
isolated?: boolean;
|
|
9
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Untyped on purpose. The tool schema builds this field conditionally and
|
|
61
|
+
* spreads it, which erases TypeBox's literal inference to `unknown`. The
|
|
62
|
+
* resolver below narrows by comparison rather than trusting the declaration,
|
|
63
|
+
* which also makes it safe for the cross-extension RPC path, where options
|
|
64
|
+
* arrive unvalidated.
|
|
65
|
+
*/
|
|
66
|
+
isolation?: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ResolveOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Whether worktree isolation is permitted at all. False when the project set
|
|
72
|
+
* `worktreeIsolation: false`, which drops a requested worktree rather than
|
|
73
|
+
* failing the call: the fail-loud precedent covers spawns that *cannot* work,
|
|
74
|
+
* while this one is the user opting out, and throwing would break exactly the
|
|
75
|
+
* calls the `"off"` value exists to tolerate. Defaults to allowed.
|
|
76
|
+
*/
|
|
77
|
+
worktreeAllowed?: boolean;
|
|
10
78
|
}
|
|
11
79
|
|
|
12
80
|
export function resolveAgentInvocationConfig(
|
|
13
81
|
agentConfig: AgentConfig | undefined,
|
|
14
82
|
params: AgentInvocationParams,
|
|
83
|
+
opts?: ResolveOptions,
|
|
15
84
|
): {
|
|
16
85
|
modelInput?: string;
|
|
17
86
|
thinking?: ThinkingLevel;
|
|
18
87
|
maxTurns?: number;
|
|
19
88
|
inheritContext: boolean;
|
|
20
|
-
runInBackground: boolean;
|
|
21
89
|
isolated: boolean;
|
|
22
90
|
isolation?: IsolationMode;
|
|
23
91
|
} {
|
|
92
|
+
// Precedence first, collapse second — reversing these loses the veto, since
|
|
93
|
+
// an agent file's "off" only outranks a caller's "worktree" while it is still
|
|
94
|
+
// a value. Everything downstream then sees "worktree" or nothing at all.
|
|
95
|
+
const requested = agentConfig?.isolation ?? params.isolation;
|
|
96
|
+
const isolation = requested === "worktree" && opts?.worktreeAllowed !== false ? "worktree" : undefined;
|
|
97
|
+
|
|
24
98
|
return {
|
|
25
99
|
modelInput: agentConfig?.model,
|
|
26
|
-
thinking:
|
|
100
|
+
thinking: agentConfig?.thinking,
|
|
27
101
|
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
|
|
28
102
|
inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
|
|
29
|
-
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
|
|
30
103
|
isolated: agentConfig?.isolated ?? params.isolated ?? false,
|
|
31
|
-
isolation
|
|
104
|
+
isolation,
|
|
32
105
|
};
|
|
33
106
|
}
|
|
34
|
-
|
|
35
|
-
export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
|
|
36
|
-
return runInBackground ? defaultJoinMode : undefined;
|
|
37
|
-
}
|
package/src/model-resolver.ts
CHANGED
|
@@ -14,6 +14,24 @@ export interface ModelRegistry {
|
|
|
14
14
|
getAvailable?(): any[];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Both display forms of a model. The short one goes on tight rows (the widget,
|
|
19
|
+
* the Agent tool result), the canonical one where there is room to disambiguate
|
|
20
|
+
* two providers serving a similarly-named model (the conversation viewer).
|
|
21
|
+
*
|
|
22
|
+
* One function, because `index.ts` labels the model it resolved before the run
|
|
23
|
+
* and `agent-manager.ts` relabels it from the live session afterwards — the two
|
|
24
|
+
* must agree or the label would visibly change the moment the session starts.
|
|
25
|
+
*/
|
|
26
|
+
export function describeModel(
|
|
27
|
+
model: { provider: string; id: string; name?: string },
|
|
28
|
+
): { modelName: string; modelId: string } {
|
|
29
|
+
return {
|
|
30
|
+
modelName: (model.name ?? model.id).replace(/^Claude\s+/i, "").toLowerCase(),
|
|
31
|
+
modelId: `${model.provider}/${model.id}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
/**
|
|
18
36
|
* Resolve a model string to a Model instance.
|
|
19
37
|
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-scope.ts — the `scopeModels` policy, shared by the top-level Agent tool
|
|
3
|
+
* and the nested delegation tools so a nested spawn cannot escape the allowlist
|
|
4
|
+
* the top-level path enforces.
|
|
5
|
+
*
|
|
6
|
+
* State lives here (rather than in an index.ts closure) for the same reason
|
|
7
|
+
* `disableDefaults` lives in agent-types.ts: both entry points need it, and a
|
|
8
|
+
* setting only one path can read is a setting the other path silently ignores.
|
|
9
|
+
*
|
|
10
|
+
* This fork has no caller-supplied model (the `model`/`thinking` tool params
|
|
11
|
+
* were removed), so every effective model is either frontmatter-pinned or
|
|
12
|
+
* inherited from the parent — the policy is always warn-and-proceed. See the
|
|
13
|
+
* SubagentsSettings.scopeModels docstring for the rationale.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { isModelInScope, type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
17
|
+
import { toSingleLine } from "./text-safety.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* When enabled, subagent model choices are validated against `enabledModels`
|
|
21
|
+
* from pi's settings — both global `<agentDir>/settings.json` and project-local
|
|
22
|
+
* `<cwd>/.pi/settings.json` (project overrides global). Off by default; opt-in
|
|
23
|
+
* via `/agents → Settings`.
|
|
24
|
+
*/
|
|
25
|
+
let scopeModelsEnabled = false;
|
|
26
|
+
|
|
27
|
+
export function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
|
|
28
|
+
export function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
|
|
29
|
+
|
|
30
|
+
export interface ModelScopeCheck {
|
|
31
|
+
model: { provider: string; id: string } | undefined;
|
|
32
|
+
/** Where pi's settings are read from — the branch's config root. */
|
|
33
|
+
cwd: string;
|
|
34
|
+
modelRegistry: ModelRegistryRef;
|
|
35
|
+
/** Display name used in the warning. */
|
|
36
|
+
agentLabel: string;
|
|
37
|
+
/** The raw `model:` frontmatter input, when there was one. */
|
|
38
|
+
modelInput?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check the effective resolved model against the user's enabledModels list.
|
|
43
|
+
*
|
|
44
|
+
* Returns the warning to surface, or undefined when the model is in scope or
|
|
45
|
+
* there is nothing to validate against (feature off, no allowlist). Both
|
|
46
|
+
* entry points proceed regardless: frontmatter is authoritative (the agent's
|
|
47
|
+
* author/installer chose it) and an inherited model was chosen by the user when
|
|
48
|
+
* starting the session.
|
|
49
|
+
*/
|
|
50
|
+
export function modelScopeWarning(args: ModelScopeCheck): string | undefined {
|
|
51
|
+
const { model, cwd, modelRegistry, agentLabel, modelInput } = args;
|
|
52
|
+
if (!scopeModelsEnabled || !model) return undefined;
|
|
53
|
+
|
|
54
|
+
const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
|
|
55
|
+
if (!allowed || isModelInScope(model, allowed)) return undefined;
|
|
56
|
+
|
|
57
|
+
// Both fields come from agent frontmatter (untrusted, LLM- or user-authored)
|
|
58
|
+
// and land in a toast or a tool result: collapse newlines and strip control
|
|
59
|
+
// bytes here so neither caller has to remember.
|
|
60
|
+
const modelLabel = toSingleLine(modelInput ?? `${model.provider}/${model.id}`);
|
|
61
|
+
return `Agent "${toSingleLine(agentLabel)}" using out-of-scope model "${modelLabel}"`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nested-tools.ts — the child-safe delegation tools a subagent gets when it
|
|
3
|
+
* opts into nesting (`allowed_subagents`) and the branch still has depth left.
|
|
4
|
+
*
|
|
5
|
+
* These are injected as the child session's `customTools`, so the main session's
|
|
6
|
+
* tool spec cost stays zero: an agent that never opts in never pays for them,
|
|
7
|
+
* and an agent at the depth cap gets nothing at all (which is what makes
|
|
8
|
+
* `maxSubagentDepth` 0/1 mean "nesting off" rather than "nesting always fails").
|
|
9
|
+
*
|
|
10
|
+
* Every tool is ownership-scoped to the parent agent instance that created the
|
|
11
|
+
* set, and nested spawns resolve agent types from a registry built for the
|
|
12
|
+
* branch's own config root instead of the process-global one.
|
|
13
|
+
*
|
|
14
|
+
* This fork launches background runs only, so — unlike upstream's foreground
|
|
15
|
+
* path — the Agent tool always returns an id. `get_subagent_result` therefore
|
|
16
|
+
* keeps the fork's full report (turn/max-turns header, latest checkpoint,
|
|
17
|
+
* artifact paths) and adds an opt-in `wait` so a parent can block on its child
|
|
18
|
+
* without polling; cancelling that wait leaves the child running.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
22
|
+
import { type AgentSession, defineTool, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Type } from "@sinclair/typebox";
|
|
24
|
+
import { abortable } from "./abortable.js";
|
|
25
|
+
import { buildAgentRegistry, getAgentConfigIn, getAvailableTypesIn, isValidTypeIn, resolveTypeIn } from "./agent-types.js";
|
|
26
|
+
import { saveCheckpoint } from "./checkpoint.js";
|
|
27
|
+
import { loadCustomAgents } from "./custom-agents.js";
|
|
28
|
+
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
|
|
29
|
+
import { resolveModel } from "./model-resolver.js";
|
|
30
|
+
import { modelScopeWarning } from "./model-scope.js";
|
|
31
|
+
import { createOutputFilePath, getOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
32
|
+
import { renderAgentReport } from "./result-report.js";
|
|
33
|
+
import type { AgentConfig, AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "./types.js";
|
|
34
|
+
import { addUsage } from "./usage.js";
|
|
35
|
+
import { isWorktreeIsolationEnabled } from "./worktree.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hard ceiling on nesting for every branch: main session = 0, its subagents = 1,
|
|
39
|
+
* their children = 2. `0`/`1` disables nesting entirely. Set from
|
|
40
|
+
* `subagents.json` (`maxSubagentDepth`). Read when a subagent session is built,
|
|
41
|
+
* so a change applies to sessions started after it.
|
|
42
|
+
*/
|
|
43
|
+
let maxSubagentDepth = 2;
|
|
44
|
+
|
|
45
|
+
export function getMaxSubagentDepth(): number { return maxSubagentDepth; }
|
|
46
|
+
export function setMaxSubagentDepth(n: number): void { maxSubagentDepth = Math.max(0, Math.floor(n)); }
|
|
47
|
+
|
|
48
|
+
/** Spawn options a nested tool hands the manager. A subset of the manager's
|
|
49
|
+
* SpawnOptions plus the branch metadata it must carry down. */
|
|
50
|
+
export interface NestedSpawnOptions {
|
|
51
|
+
description: string;
|
|
52
|
+
model?: Model<any>;
|
|
53
|
+
maxTurns?: number;
|
|
54
|
+
isolated?: boolean;
|
|
55
|
+
inheritContext?: boolean;
|
|
56
|
+
thinkingLevel?: ThinkingLevel;
|
|
57
|
+
isolation?: IsolationMode;
|
|
58
|
+
invocation?: AgentInvocation;
|
|
59
|
+
/** Nesting depth of the CHILD being spawned. */
|
|
60
|
+
depth: number;
|
|
61
|
+
parentAgentId: string;
|
|
62
|
+
maxSubagentDepth: number;
|
|
63
|
+
configCwd: string;
|
|
64
|
+
rootSessionId?: string;
|
|
65
|
+
/** Resolved from this branch's registry; the run must not re-resolve it globally. */
|
|
66
|
+
agentConfig?: AgentConfig;
|
|
67
|
+
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
68
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The manager surface nested tools need. Structural so tests can fake it. */
|
|
72
|
+
export interface NestedAgentManager {
|
|
73
|
+
spawn(pi: ExtensionAPI, ctx: ExtensionContext, type: string, prompt: string, options: NestedSpawnOptions): string;
|
|
74
|
+
resume(
|
|
75
|
+
id: string,
|
|
76
|
+
prompt: string,
|
|
77
|
+
callbacks?: { onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void },
|
|
78
|
+
): AgentRecord | undefined;
|
|
79
|
+
getRecord(id: string): AgentRecord | undefined;
|
|
80
|
+
listAgents(): AgentRecord[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface NestedToolContext {
|
|
84
|
+
manager: NestedAgentManager;
|
|
85
|
+
pi: ExtensionAPI;
|
|
86
|
+
/** The agent this set belongs to — the ownership key for every call. */
|
|
87
|
+
parentAgentId: string;
|
|
88
|
+
/** This agent's own depth; children spawn at depth + 1. */
|
|
89
|
+
depth: number;
|
|
90
|
+
maxSubagentDepth: number;
|
|
91
|
+
/** "all" = any enabled agent; string[] = only those types. Never empty. */
|
|
92
|
+
allowedSubagents: "all" | string[];
|
|
93
|
+
/** Root used for agent/config discovery; may differ from the agent's working directory. */
|
|
94
|
+
configCwd: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function textResult(text: string, isError = false) {
|
|
98
|
+
return { content: [{ type: "text" as const, text }], isError, details: undefined };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A record is reachable only through the agent that spawned it. */
|
|
102
|
+
function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): record is AgentRecord {
|
|
103
|
+
return record?.parentAgentId === parentAgentId;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Build child-safe orchestration tools scoped to one parent agent instance. */
|
|
107
|
+
export function createNestedSubagentTools(context: NestedToolContext): ToolDefinition[] {
|
|
108
|
+
// Agents resolve from a registry built for THIS branch's config root (under
|
|
109
|
+
// worktree isolation, the copy). Never via registerAgents — that is
|
|
110
|
+
// process-global state shared with the main session and every other agent.
|
|
111
|
+
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd));
|
|
112
|
+
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
|
113
|
+
context.allowedSubagents === "all"
|
|
114
|
+
? undefined
|
|
115
|
+
: new Set(context.allowedSubagents.map(name => resolveTypeIn(registry, name) ?? name));
|
|
116
|
+
const availableIn = (registry: Map<string, AgentConfig>): string[] => {
|
|
117
|
+
const allowed = allowedTypesIn(registry);
|
|
118
|
+
return getAvailableTypesIn(registry).filter(name => allowed === undefined || allowed.has(name));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const agentTool = defineTool({
|
|
122
|
+
name: "Agent",
|
|
123
|
+
label: "Agent",
|
|
124
|
+
description:
|
|
125
|
+
"Launch a child-safe nested subagent for bounded delegated work. Runs in the background and returns an " +
|
|
126
|
+
"agent ID; poll it with get_subagent_result. Only the agent types this parent is allowed to delegate to " +
|
|
127
|
+
"are accepted, and nesting is depth-limited.",
|
|
128
|
+
promptSnippet: "Launch a nested subagent for bounded delegated work",
|
|
129
|
+
promptGuidelines: [
|
|
130
|
+
"Nested subagents run in the background: keep working, then read the result with get_subagent_result (use wait to block).",
|
|
131
|
+
"Never delegate understanding — hand over the exact task, files, and what you already know.",
|
|
132
|
+
],
|
|
133
|
+
parameters: Type.Object({
|
|
134
|
+
prompt: Type.String({ description: "Self-contained task for the nested agent." }),
|
|
135
|
+
description: Type.String({ description: "Short 3-5 word task description." }),
|
|
136
|
+
subagent_type: Type.String({
|
|
137
|
+
description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.`,
|
|
138
|
+
}),
|
|
139
|
+
max_turns: Type.Optional(
|
|
140
|
+
Type.Number({ description: "Maximum number of agentic turns before stopping. Omit for the agent's own limit.", minimum: 1 }),
|
|
141
|
+
),
|
|
142
|
+
resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
|
|
143
|
+
isolated: Type.Optional(Type.Boolean({ description: "If true, the nested agent gets no extension tools." })),
|
|
144
|
+
inherit_context: Type.Optional(
|
|
145
|
+
Type.Boolean({ description: "If true, fork this agent's conversation into the nested agent." }),
|
|
146
|
+
),
|
|
147
|
+
// Same shape as the top-level tool: the parameter disappears entirely when
|
|
148
|
+
// worktree isolation is switched off, so a model filling every optional
|
|
149
|
+
// field cannot select a mode the project refuses.
|
|
150
|
+
...isolationParam(isWorktreeIsolationEnabled()),
|
|
151
|
+
}),
|
|
152
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
153
|
+
// Usage a nested child reports belongs to every ancestor: nested records
|
|
154
|
+
// are hidden from every reporting surface, so their spend would otherwise
|
|
155
|
+
// be unattributable. The whole chain is walked — a spawn callback only
|
|
156
|
+
// fires for that child's OWN turns, so stopping at one level would hide a
|
|
157
|
+
// great-grandchild from the only record anyone can see.
|
|
158
|
+
const foldUsageIntoAncestors = (usage: { input: number; output: number; cacheWrite: number }): void => {
|
|
159
|
+
for (let id: string | undefined = context.parentAgentId; id !== undefined; ) {
|
|
160
|
+
const ancestor = context.manager.getRecord(id);
|
|
161
|
+
if (!ancestor) break;
|
|
162
|
+
addUsage(ancestor.lifetimeUsage, usage);
|
|
163
|
+
id = ancestor.parentAgentId;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (params.resume) {
|
|
168
|
+
const existing = context.manager.getRecord(params.resume);
|
|
169
|
+
if (!ownsRecord(existing, context.parentAgentId)) {
|
|
170
|
+
return textResult(`Nested agent not found or not owned by this parent: "${params.resume}".`, true);
|
|
171
|
+
}
|
|
172
|
+
const resumed = context.manager.resume(params.resume, params.prompt, {
|
|
173
|
+
onAssistantUsage: foldUsageIntoAncestors,
|
|
174
|
+
});
|
|
175
|
+
if (!resumed) {
|
|
176
|
+
return textResult(`Failed to resume nested agent "${params.resume}" — it is still active or has no session.`, true);
|
|
177
|
+
}
|
|
178
|
+
return textResult(
|
|
179
|
+
`Nested agent resumed in background.\nAgent ID: ${resumed.id}\n\n` +
|
|
180
|
+
`Use get_subagent_result to retrieve its result. Do not duplicate its work.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (context.depth >= context.maxSubagentDepth) {
|
|
185
|
+
return textResult(
|
|
186
|
+
`Nested subagent call blocked (depth=${context.depth}, max=${context.maxSubagentDepth}). Complete the task directly.`,
|
|
187
|
+
true,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Reloaded per call so new agent files are picked up without a restart.
|
|
192
|
+
const registry = loadRegistry();
|
|
193
|
+
const rawType = params.subagent_type;
|
|
194
|
+
const resolvedType = resolveTypeIn(registry, rawType);
|
|
195
|
+
if (!resolvedType || !isValidTypeIn(registry, resolvedType)) {
|
|
196
|
+
return textResult(`Unknown or disabled nested agent type: "${rawType}".`, true);
|
|
197
|
+
}
|
|
198
|
+
const allowed = allowedTypesIn(registry);
|
|
199
|
+
if (allowed !== undefined && !allowed.has(resolvedType)) {
|
|
200
|
+
return textResult(
|
|
201
|
+
`Nested agent type "${resolvedType}" is not allowed for this parent. Allowed: ${[...allowed].join(", ")}.`,
|
|
202
|
+
true,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The config resolved here is handed to the run as well: `runAgent` would
|
|
207
|
+
// otherwise resolve the type against the process-global registry, and an
|
|
208
|
+
// agent that exists only in this branch's config root would silently run
|
|
209
|
+
// as the unknown-type fallback — general-purpose, every tool.
|
|
210
|
+
const config = getAgentConfigIn(registry, resolvedType);
|
|
211
|
+
const invocation = resolveAgentInvocationConfig(config, params, {
|
|
212
|
+
worktreeAllowed: isWorktreeIsolationEnabled(),
|
|
213
|
+
});
|
|
214
|
+
let model = ctx.model;
|
|
215
|
+
if (invocation.modelInput) {
|
|
216
|
+
const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry);
|
|
217
|
+
if (typeof resolvedModel !== "string") model = resolvedModel;
|
|
218
|
+
}
|
|
219
|
+
// Same scopeModels policy as the top-level Agent tool — a nested spawn
|
|
220
|
+
// must not escape the allowlist. A child session has no UI surface to
|
|
221
|
+
// toast to, so the warning rides back in the tool result where the parent
|
|
222
|
+
// model can act on it.
|
|
223
|
+
const scopeWarning = modelScopeWarning({
|
|
224
|
+
model,
|
|
225
|
+
cwd: context.configCwd,
|
|
226
|
+
modelRegistry: ctx.modelRegistry,
|
|
227
|
+
agentLabel: config?.displayName ?? resolvedType,
|
|
228
|
+
modelInput: invocation.modelInput,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// The whole branch shares the root session's transcript directory; read it
|
|
232
|
+
// off the owning parent rather than this child session's own id.
|
|
233
|
+
const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId;
|
|
234
|
+
const childDepth = context.depth + 1;
|
|
235
|
+
|
|
236
|
+
// Transcript wiring, same gate as the top-level path: the child's
|
|
237
|
+
// `output_transcript` frontmatter wins, else the project default. Filed
|
|
238
|
+
// under the ROOT session so a nested transcript lands in the same
|
|
239
|
+
// `tasks/` directory as its ancestors' rather than in a directory of its
|
|
240
|
+
// own.
|
|
241
|
+
const transcriptSessionId =
|
|
242
|
+
rootSessionId !== undefined && (config?.outputTranscript ?? getOutputTranscriptDefault())
|
|
243
|
+
? rootSessionId
|
|
244
|
+
: undefined;
|
|
245
|
+
let childId: string | undefined;
|
|
246
|
+
|
|
247
|
+
const options: NestedSpawnOptions = {
|
|
248
|
+
description: params.description,
|
|
249
|
+
model,
|
|
250
|
+
maxTurns: invocation.maxTurns,
|
|
251
|
+
isolated: invocation.isolated,
|
|
252
|
+
inheritContext: invocation.inheritContext,
|
|
253
|
+
thinkingLevel: invocation.thinking,
|
|
254
|
+
isolation: invocation.isolation,
|
|
255
|
+
invocation: {
|
|
256
|
+
thinking: invocation.thinking,
|
|
257
|
+
maxTurns: invocation.maxTurns,
|
|
258
|
+
isolated: invocation.isolated,
|
|
259
|
+
inheritContext: invocation.inheritContext,
|
|
260
|
+
isolation: invocation.isolation,
|
|
261
|
+
},
|
|
262
|
+
agentConfig: config,
|
|
263
|
+
depth: childDepth,
|
|
264
|
+
parentAgentId: context.parentAgentId,
|
|
265
|
+
maxSubagentDepth: context.maxSubagentDepth,
|
|
266
|
+
configCwd: context.configCwd,
|
|
267
|
+
rootSessionId,
|
|
268
|
+
onAssistantUsage: foldUsageIntoAncestors,
|
|
269
|
+
// Fires inside runAgent once the child session exists. The record's
|
|
270
|
+
// outputFile is attached synchronously after spawn returns, so this
|
|
271
|
+
// reads it lazily rather than closing over a value that doesn't exist yet.
|
|
272
|
+
onSessionCreated: (session) => {
|
|
273
|
+
const rec = childId === undefined ? undefined : context.manager.getRecord(childId);
|
|
274
|
+
if (rec?.outputFile && childId !== undefined) {
|
|
275
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, childId, ctx.cwd);
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// `ctx` is forwarded to the manager unmodified, never captured at
|
|
281
|
+
// tool-build time: each AgentSession builds its own ExtensionRunner from
|
|
282
|
+
// that session's cwd/sessionManager/modelRegistry, so this is the CHILD's
|
|
283
|
+
// context. Capturing one earlier would silently give a grandchild the
|
|
284
|
+
// wrong worktree base, the wrong conversation under inherit_context, and
|
|
285
|
+
// the wrong inherited model.
|
|
286
|
+
//
|
|
287
|
+
// spawn() throws on strict worktree-isolation failure and cwd validation —
|
|
288
|
+
// report it as a tool error, like the top-level Agent tool does, instead
|
|
289
|
+
// of letting it escape into the child's turn.
|
|
290
|
+
let id: string;
|
|
291
|
+
try {
|
|
292
|
+
id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, options);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
return textResult(err instanceof Error ? err.message : String(err), true);
|
|
295
|
+
}
|
|
296
|
+
childId = id;
|
|
297
|
+
|
|
298
|
+
if (transcriptSessionId !== undefined) {
|
|
299
|
+
const rec = context.manager.getRecord(id);
|
|
300
|
+
if (rec) {
|
|
301
|
+
rec.outputFile = createOutputFilePath(context.configCwd, id, transcriptSessionId);
|
|
302
|
+
writeInitialEntry(rec.outputFile, id, params.prompt, ctx.cwd);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const record = context.manager.getRecord(id);
|
|
307
|
+
return textResult(
|
|
308
|
+
`Nested agent ${record?.status === "queued" ? "queued" : "started"} in background.\n` +
|
|
309
|
+
`Agent ID: ${id}\n` +
|
|
310
|
+
`Type: ${resolvedType}\n` +
|
|
311
|
+
`Description: ${params.description}\n` +
|
|
312
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
313
|
+
(scopeWarning ? `Warning: ${scopeWarning}\n` : "") +
|
|
314
|
+
`\nUse get_subagent_result to retrieve its result. Do not duplicate its work.`,
|
|
315
|
+
);
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const resultTool = defineTool({
|
|
320
|
+
name: "get_subagent_result",
|
|
321
|
+
label: "Get Nested Agent Result",
|
|
322
|
+
description:
|
|
323
|
+
"Check a nested agent owned by this parent, or block until it settles with wait: true. " +
|
|
324
|
+
"Cancelling the wait leaves the agent running.",
|
|
325
|
+
promptSnippet: "Check or wait for a nested subagent's result",
|
|
326
|
+
parameters: Type.Object({
|
|
327
|
+
agent_id: Type.String({ description: "The nested agent ID returned by the Agent tool." }),
|
|
328
|
+
wait: Type.Optional(
|
|
329
|
+
Type.Boolean({ description: "If true, block until the agent settles before reporting. Default: false." }),
|
|
330
|
+
),
|
|
331
|
+
}),
|
|
332
|
+
execute: async (_toolCallId, params, signal) => {
|
|
333
|
+
const record = context.manager.getRecord(params.agent_id);
|
|
334
|
+
if (!ownsRecord(record, context.parentAgentId)) {
|
|
335
|
+
return textResult(`Nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
|
|
336
|
+
}
|
|
337
|
+
// Cancellation (the parent's tool call is aborted) stops only this wait;
|
|
338
|
+
// the nested child keeps running and stays fetchable. A queued record has
|
|
339
|
+
// no promise until the manager starts it, so poll — abortably — until it
|
|
340
|
+
// leaves the queue, then await.
|
|
341
|
+
if (params.wait && (record.status === "queued" || record.status === "running")) {
|
|
342
|
+
while (record.status === "queued") {
|
|
343
|
+
await abortable(new Promise<void>(resolve => setTimeout(resolve, 250)), signal);
|
|
344
|
+
}
|
|
345
|
+
if (record.promise) await abortable(record.promise, signal);
|
|
346
|
+
}
|
|
347
|
+
return textResult(renderAgentReport(record), record.status === "error");
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const steerTool = defineTool({
|
|
352
|
+
name: "steer_subagent",
|
|
353
|
+
label: "Steer Nested Agent",
|
|
354
|
+
description: "Send a steering message to a running nested agent owned by this parent.",
|
|
355
|
+
promptSnippet: "Send a steering message to redirect a nested subagent",
|
|
356
|
+
parameters: Type.Object({
|
|
357
|
+
agent_id: Type.String({ description: "The nested agent ID to steer." }),
|
|
358
|
+
message: Type.String({ description: "The steering message to send." }),
|
|
359
|
+
}),
|
|
360
|
+
execute: async (_toolCallId, params) => {
|
|
361
|
+
const record = context.manager.getRecord(params.agent_id);
|
|
362
|
+
if (!ownsRecord(record, context.parentAgentId)) {
|
|
363
|
+
return textResult(`Nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
|
|
364
|
+
}
|
|
365
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
366
|
+
return textResult(`Nested agent "${params.agent_id}" is not running (status: ${record.status}).`, true);
|
|
367
|
+
}
|
|
368
|
+
// Session not ready yet — queue the steer. The manager flushes pending
|
|
369
|
+
// steers when the session is created (same contract as the top-level tool).
|
|
370
|
+
if (!record.session) {
|
|
371
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
372
|
+
record.pendingSteers.push(params.message);
|
|
373
|
+
return textResult(`Steering message queued for nested agent ${record.id}. It will be delivered once the session initializes.`);
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
await record.session.steer(params.message);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
return textResult(`Failed to steer nested agent: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
379
|
+
}
|
|
380
|
+
return textResult(`Steering message sent to nested agent ${record.id}.`);
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const checkpointTool = defineTool({
|
|
385
|
+
name: "checkpoint",
|
|
386
|
+
label: "Checkpoint",
|
|
387
|
+
description:
|
|
388
|
+
"Save a progress checkpoint for the parent. Call at milestones — when you finish a phase, hit a dead end, " +
|
|
389
|
+
"or change approach. Include what you just did and what you're doing next.",
|
|
390
|
+
promptSnippet: "Save a progress checkpoint for the parent",
|
|
391
|
+
parameters: Type.Object({
|
|
392
|
+
summary: Type.String({ description: "1-2 sentences: what you just did, what you're doing next" }),
|
|
393
|
+
}),
|
|
394
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
395
|
+
// Records live in the manager that spawned this session — resolve by the
|
|
396
|
+
// child's own session id rather than by ownership, exactly as the
|
|
397
|
+
// top-level registration does.
|
|
398
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
399
|
+
const record = context.manager.listAgents().find(r => r.sessionId === sessionId);
|
|
400
|
+
if (!record) return textResult("Checkpoint failed: agent record not found.");
|
|
401
|
+
return textResult(saveCheckpoint(record, params.summary));
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return [agentTool, resultTool, steerTool, checkpointTool];
|
|
406
|
+
}
|
package/src/output-file.ts
CHANGED
|
@@ -10,6 +10,20 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Project/global default for writing a subagent's `.output` transcript; a custom
|
|
15
|
+
* agent's `output_transcript` overrides it per agent.
|
|
16
|
+
*
|
|
17
|
+
* State lives here rather than in an index.ts closure because both spawn paths
|
|
18
|
+
* need it — the top-level Agent tool and the nested delegation tools. Same
|
|
19
|
+
* reason `scopeModels` lives in model-scope.ts: a setting only one path can read
|
|
20
|
+
* is a setting the other path silently ignores.
|
|
21
|
+
*/
|
|
22
|
+
let outputTranscriptDefault = true;
|
|
23
|
+
|
|
24
|
+
export function getOutputTranscriptDefault(): boolean { return outputTranscriptDefault; }
|
|
25
|
+
export function setOutputTranscriptDefault(b: boolean): void { outputTranscriptDefault = b; }
|
|
26
|
+
|
|
13
27
|
/**
|
|
14
28
|
* Encode a cwd path as a filesystem-safe directory name. Handles:
|
|
15
29
|
* - POSIX: "/home/user/project" → "home-user-project"
|
|
@@ -63,8 +77,11 @@ export function streamToOutputFile(
|
|
|
63
77
|
path: string,
|
|
64
78
|
agentId: string,
|
|
65
79
|
cwd: string,
|
|
80
|
+
startIndex = 1,
|
|
66
81
|
): () => void {
|
|
67
|
-
|
|
82
|
+
// Messages before `startIndex` are already on disk (the initial user prompt
|
|
83
|
+
// for a fresh spawn; the prior run's transcript for a resume).
|
|
84
|
+
let writtenCount = startIndex;
|
|
68
85
|
|
|
69
86
|
const flush = () => {
|
|
70
87
|
const messages = session.messages;
|