@9thprotocol/agent-core 0.1.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/LICENSE +16 -0
- package/README.md +10 -0
- package/dist/compaction.d.ts +69 -0
- package/dist/compaction.js +174 -0
- package/dist/delegate.d.ts +84 -0
- package/dist/delegate.js +135 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +78 -0
- package/dist/memory.d.ts +7 -0
- package/dist/memory.js +33 -0
- package/dist/model/openrouter.d.ts +61 -0
- package/dist/model/openrouter.js +135 -0
- package/dist/model/router.d.ts +60 -0
- package/dist/model/router.js +171 -0
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.js +16 -0
- package/dist/prompt.d.ts +5 -0
- package/dist/prompt.js +31 -0
- package/dist/scripts/compaction-live.d.ts +1 -0
- package/dist/scripts/compaction-live.js +80 -0
- package/dist/scripts/compaction-smoke.d.ts +1 -0
- package/dist/scripts/compaction-smoke.js +143 -0
- package/dist/scripts/delegation-live.d.ts +1 -0
- package/dist/scripts/delegation-live.js +122 -0
- package/dist/scripts/delegation-smoke.d.ts +1 -0
- package/dist/scripts/delegation-smoke.js +140 -0
- package/dist/scripts/router-live.d.ts +1 -0
- package/dist/scripts/router-live.js +73 -0
- package/dist/scripts/router-smoke.d.ts +1 -0
- package/dist/scripts/router-smoke.js +58 -0
- package/dist/scripts/smoke.d.ts +1 -0
- package/dist/scripts/smoke.js +52 -0
- package/dist/session.d.ts +73 -0
- package/dist/session.js +574 -0
- package/dist/skills.d.ts +14 -0
- package/dist/skills.js +56 -0
- package/dist/tools/bash.d.ts +2 -0
- package/dist/tools/bash.js +38 -0
- package/dist/tools/fs-tools.d.ts +5 -0
- package/dist/tools/fs-tools.js +115 -0
- package/dist/tools/registry.d.ts +5 -0
- package/dist/tools/registry.js +12 -0
- package/dist/tools/search-tools.d.ts +3 -0
- package/dist/tools/search-tools.js +84 -0
- package/dist/tools/types.d.ts +27 -0
- package/dist/tools/types.js +15 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.js +2 -0
- package/dist/vault.d.ts +13 -0
- package/dist/vault.js +81 -0
- package/package.json +29 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentEvent, DelegationTotals, PermissionMode, SessionOptions, UsageTotals } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* One conversation with the agent. `send()` streams AgentEvents; messages
|
|
4
|
+
* accumulate across sends until `clear()`.
|
|
5
|
+
*/
|
|
6
|
+
export declare class AgentSession {
|
|
7
|
+
private readonly opts;
|
|
8
|
+
/**
|
|
9
|
+
* Stable id for this conversation, sent with every platform request so the
|
|
10
|
+
* ledger can attribute spend to a session. M7 calibrates cost *per session*
|
|
11
|
+
* (PLAN.md §5.1); without this the ledger can only aggregate per user/day.
|
|
12
|
+
* Sub-agents inherit the parent's id so their burn rolls into the same session.
|
|
13
|
+
*/
|
|
14
|
+
readonly sessionId: string;
|
|
15
|
+
/** The model that actually ran the last turn (resolved, never `"auto"`). */
|
|
16
|
+
model: string;
|
|
17
|
+
mode: PermissionMode;
|
|
18
|
+
readonly usage: UsageTotals;
|
|
19
|
+
/**
|
|
20
|
+
* Running delegation totals. `usage` already includes the worker's burn;
|
|
21
|
+
* this is the separate accounting of what it bought, so hosts can show the
|
|
22
|
+
* one number the session-window pitch rests on: context that never had to be
|
|
23
|
+
* paid for, on this turn or any turn after it.
|
|
24
|
+
*/
|
|
25
|
+
readonly delegated: DelegationTotals;
|
|
26
|
+
private messages;
|
|
27
|
+
private readonly ctx;
|
|
28
|
+
private readonly tools;
|
|
29
|
+
private readonly memory;
|
|
30
|
+
private readonly depth;
|
|
31
|
+
/** `"auto"` (route per turn) or a pinned model id. Toggled by `setModel`. */
|
|
32
|
+
private modelPreference;
|
|
33
|
+
/** Set once compaction fails to shrink, so we stop paying to retry it. */
|
|
34
|
+
private compactionIneffective;
|
|
35
|
+
/** Whether bulk_read/code_write exist and the read gate is armed. */
|
|
36
|
+
private readonly delegationOn;
|
|
37
|
+
constructor(opts: SessionOptions);
|
|
38
|
+
/** Tool names available this session, for hosts and tests. */
|
|
39
|
+
get toolNames(): string[];
|
|
40
|
+
/** True while the session lets the router pick per turn. */
|
|
41
|
+
get isAuto(): boolean;
|
|
42
|
+
/** Pin a model, or pass `"auto"` to hand routing back to the router. */
|
|
43
|
+
setModel(id: string): void;
|
|
44
|
+
/** Estimated prompt tokens the next request would send. */
|
|
45
|
+
get contextTokens(): number;
|
|
46
|
+
/** Context window of the model that will run, from the catalog when known. */
|
|
47
|
+
private get contextLimit();
|
|
48
|
+
/**
|
|
49
|
+
* Summarise older history into one message, keeping recent turns verbatim.
|
|
50
|
+
* Returns null when there was nothing safe to compact.
|
|
51
|
+
*
|
|
52
|
+
* The summary is produced by a real model call, cheap and toolless, because
|
|
53
|
+
* a mechanical truncation loses exactly the decisions and constraints that
|
|
54
|
+
* make the rest of the session coherent.
|
|
55
|
+
*/
|
|
56
|
+
compact(): Promise<{
|
|
57
|
+
before: number;
|
|
58
|
+
after: number;
|
|
59
|
+
} | null>;
|
|
60
|
+
clear(): void;
|
|
61
|
+
send(userText: string): AsyncGenerator<AgentEvent>;
|
|
62
|
+
private addUsage;
|
|
63
|
+
private makeAskUserTool;
|
|
64
|
+
/** Cheapest model the plan allows, unless the host pinned one. */
|
|
65
|
+
private get workerModelId();
|
|
66
|
+
/** Shared plumbing for both delegating tools. */
|
|
67
|
+
private runDelegation;
|
|
68
|
+
/** Read the requested paths, failing loudly on any that are missing. */
|
|
69
|
+
private loadFiles;
|
|
70
|
+
private makeBulkReadTool;
|
|
71
|
+
private makeCodeWriteTool;
|
|
72
|
+
private makeTaskTool;
|
|
73
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { buildSystemPrompt } from "./prompt.js";
|
|
5
|
+
import { loadMemory } from "./memory.js";
|
|
6
|
+
import { gateFor } from "./permissions.js";
|
|
7
|
+
import { CORE_TOOLS } from "./tools/registry.js";
|
|
8
|
+
import { str, toSchema, truncate } from "./tools/types.js";
|
|
9
|
+
import { ApiError, streamChat, } from "./model/openrouter.js";
|
|
10
|
+
import { AUTO_MODEL, FALLBACK_DISPLAY_MODEL, route, workerModel } from "./model/router.js";
|
|
11
|
+
import { DELEGATION_MIN_LINES, buildReadCorpus, buildWriteCorpus, delegate, estimateCorpusTokens, stripFences, } from "./delegate.js";
|
|
12
|
+
import { DEFAULT_CONTEXT_TOKENS, applyCompaction, estimateTokens, planCompaction, renderTranscript, shouldCompact, summaryPrompt, } from "./compaction.js";
|
|
13
|
+
const MAX_TURNS_DEFAULT = 40;
|
|
14
|
+
/** Cheap, long-context model for summarising history. */
|
|
15
|
+
const COMPACTION_MODEL = "z-ai/glm-4.7-flash";
|
|
16
|
+
const MAX_TOOL_RESULT = 30_000;
|
|
17
|
+
const MAX_SUBAGENT_DEPTH = 2;
|
|
18
|
+
/**
|
|
19
|
+
* One conversation with the agent. `send()` streams AgentEvents; messages
|
|
20
|
+
* accumulate across sends until `clear()`.
|
|
21
|
+
*/
|
|
22
|
+
export class AgentSession {
|
|
23
|
+
opts;
|
|
24
|
+
/**
|
|
25
|
+
* Stable id for this conversation, sent with every platform request so the
|
|
26
|
+
* ledger can attribute spend to a session. M7 calibrates cost *per session*
|
|
27
|
+
* (PLAN.md §5.1); without this the ledger can only aggregate per user/day.
|
|
28
|
+
* Sub-agents inherit the parent's id so their burn rolls into the same session.
|
|
29
|
+
*/
|
|
30
|
+
sessionId;
|
|
31
|
+
/** The model that actually ran the last turn (resolved, never `"auto"`). */
|
|
32
|
+
model;
|
|
33
|
+
mode;
|
|
34
|
+
usage = { inputTokens: 0, cachedTokens: 0, outputTokens: 0, requests: 0 };
|
|
35
|
+
/**
|
|
36
|
+
* Running delegation totals. `usage` already includes the worker's burn;
|
|
37
|
+
* this is the separate accounting of what it bought, so hosts can show the
|
|
38
|
+
* one number the session-window pitch rests on: context that never had to be
|
|
39
|
+
* paid for, on this turn or any turn after it.
|
|
40
|
+
*/
|
|
41
|
+
delegated = {
|
|
42
|
+
calls: 0,
|
|
43
|
+
contextTokensSaved: 0,
|
|
44
|
+
workerUsage: { inputTokens: 0, cachedTokens: 0, outputTokens: 0, requests: 0 },
|
|
45
|
+
};
|
|
46
|
+
messages = [];
|
|
47
|
+
ctx;
|
|
48
|
+
tools;
|
|
49
|
+
memory;
|
|
50
|
+
depth;
|
|
51
|
+
/** `"auto"` (route per turn) or a pinned model id. Toggled by `setModel`. */
|
|
52
|
+
modelPreference;
|
|
53
|
+
/** Set once compaction fails to shrink, so we stop paying to retry it. */
|
|
54
|
+
compactionIneffective = false;
|
|
55
|
+
/** Whether bulk_read/code_write exist and the read gate is armed. */
|
|
56
|
+
delegationOn;
|
|
57
|
+
constructor(opts) {
|
|
58
|
+
this.opts = opts;
|
|
59
|
+
this.sessionId = opts.sessionId ?? randomUUID();
|
|
60
|
+
this.modelPreference = opts.model;
|
|
61
|
+
// Until the first turn routes, report the fallback rather than "auto".
|
|
62
|
+
this.model = opts.model === AUTO_MODEL ? FALLBACK_DISPLAY_MODEL : opts.model;
|
|
63
|
+
this.mode = opts.mode ?? "default";
|
|
64
|
+
this.depth = opts.depth ?? 0;
|
|
65
|
+
this.memory = loadMemory(opts.cwd);
|
|
66
|
+
// Explore sub-agents already run on the worker tier, so delegating from one
|
|
67
|
+
// would pay a round trip to move work from a flash model to a flash model.
|
|
68
|
+
this.delegationOn =
|
|
69
|
+
opts.delegation?.enabled !== false && opts.subagent !== "explore";
|
|
70
|
+
const minLines = opts.delegation?.minLines ?? DELEGATION_MIN_LINES;
|
|
71
|
+
this.ctx = {
|
|
72
|
+
cwd: opts.cwd,
|
|
73
|
+
readFiles: new Set(),
|
|
74
|
+
...(this.delegationOn ? { delegation: { minLines } } : {}),
|
|
75
|
+
};
|
|
76
|
+
let tools = [...CORE_TOOLS, ...(opts.mcp?.tools ?? [])];
|
|
77
|
+
if (this.delegationOn)
|
|
78
|
+
tools.push(this.makeBulkReadTool(), this.makeCodeWriteTool());
|
|
79
|
+
if (opts.toolset === "read-only")
|
|
80
|
+
tools = tools.filter((t) => t.kind === "read");
|
|
81
|
+
if (opts.askUser)
|
|
82
|
+
tools.push(this.makeAskUserTool());
|
|
83
|
+
if (this.depth < MAX_SUBAGENT_DEPTH)
|
|
84
|
+
tools.push(this.makeTaskTool());
|
|
85
|
+
this.tools = tools;
|
|
86
|
+
// Platform mode: the API assembles the system prompt server-side per request.
|
|
87
|
+
if (!opts.platform) {
|
|
88
|
+
this.messages.push({ role: "system", content: buildSystemPrompt(opts.cwd, this.memory) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Tool names available this session, for hosts and tests. */
|
|
92
|
+
get toolNames() {
|
|
93
|
+
return this.tools.map((t) => t.name);
|
|
94
|
+
}
|
|
95
|
+
/** True while the session lets the router pick per turn. */
|
|
96
|
+
get isAuto() {
|
|
97
|
+
return this.modelPreference === AUTO_MODEL;
|
|
98
|
+
}
|
|
99
|
+
/** Pin a model, or pass `"auto"` to hand routing back to the router. */
|
|
100
|
+
setModel(id) {
|
|
101
|
+
this.modelPreference = id;
|
|
102
|
+
if (id !== AUTO_MODEL)
|
|
103
|
+
this.model = id;
|
|
104
|
+
}
|
|
105
|
+
/** Estimated prompt tokens the next request would send. */
|
|
106
|
+
get contextTokens() {
|
|
107
|
+
return estimateTokens(this.messages);
|
|
108
|
+
}
|
|
109
|
+
/** Context window of the model that will run, from the catalog when known. */
|
|
110
|
+
get contextLimit() {
|
|
111
|
+
const entry = this.opts.autoRouter?.catalog?.find((m) => m.id === this.model);
|
|
112
|
+
return entry?.contextLength ?? this.opts.contextTokens ?? DEFAULT_CONTEXT_TOKENS;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Summarise older history into one message, keeping recent turns verbatim.
|
|
116
|
+
* Returns null when there was nothing safe to compact.
|
|
117
|
+
*
|
|
118
|
+
* The summary is produced by a real model call, cheap and toolless, because
|
|
119
|
+
* a mechanical truncation loses exactly the decisions and constraints that
|
|
120
|
+
* make the rest of the session coherent.
|
|
121
|
+
*/
|
|
122
|
+
async compact() {
|
|
123
|
+
const plan = planCompaction(this.messages, this.contextLimit);
|
|
124
|
+
if (!plan)
|
|
125
|
+
return null;
|
|
126
|
+
// In platform mode the summariser gets its own server-assembled prompt via
|
|
127
|
+
// `context.mode`, so the transcript travels alone. Sending it as a plain
|
|
128
|
+
// user message would have the API prepend the full agent prompt instead:
|
|
129
|
+
// ~356 tokens against the summariser prompt's 186, and far more once a
|
|
130
|
+
// 9P.md memory is in play, all of it telling a model whose only job is to
|
|
131
|
+
// summarise that it should investigate and edit files.
|
|
132
|
+
const transcript = renderTranscript(plan.toSummarise);
|
|
133
|
+
let summary = "";
|
|
134
|
+
for await (const ev of streamChat({
|
|
135
|
+
apiKey: this.opts.apiKey,
|
|
136
|
+
model: this.opts.compactionModel ?? COMPACTION_MODEL,
|
|
137
|
+
messages: [
|
|
138
|
+
{
|
|
139
|
+
role: "user",
|
|
140
|
+
content: this.opts.platform
|
|
141
|
+
? `TRANSCRIPT:\n${transcript}`
|
|
142
|
+
: summaryPrompt(transcript),
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
tools: [],
|
|
146
|
+
...(this.opts.platform
|
|
147
|
+
? {
|
|
148
|
+
baseUrl: this.opts.platform.baseUrl,
|
|
149
|
+
extraBody: {
|
|
150
|
+
context: {
|
|
151
|
+
cwd: this.opts.cwd,
|
|
152
|
+
sessionId: this.sessionId,
|
|
153
|
+
mode: "compaction",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
: {}),
|
|
158
|
+
})) {
|
|
159
|
+
if (ev.type === "done") {
|
|
160
|
+
summary = ev.result.message.content ?? "";
|
|
161
|
+
this.addUsage(ev.result.usage);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// A failed or empty summary must not destroy history.
|
|
165
|
+
if (!summary.trim())
|
|
166
|
+
return null;
|
|
167
|
+
const compacted = applyCompaction(plan, summary);
|
|
168
|
+
const after = estimateTokens(compacted);
|
|
169
|
+
// A summary can come back longer than what it replaced (verbose model, or
|
|
170
|
+
// very little history to compress). Keeping it would grow the context AND
|
|
171
|
+
// re-trigger compaction next turn, a loop that burns a model call per turn
|
|
172
|
+
// and never converges. Discard it and stop auto-compacting instead.
|
|
173
|
+
if (after >= plan.beforeTokens) {
|
|
174
|
+
this.compactionIneffective = true;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
this.messages = compacted;
|
|
178
|
+
return { before: plan.beforeTokens, after };
|
|
179
|
+
}
|
|
180
|
+
clear() {
|
|
181
|
+
this.messages = this.opts.platform
|
|
182
|
+
? []
|
|
183
|
+
: [{ role: "system", content: buildSystemPrompt(this.opts.cwd, this.memory) }];
|
|
184
|
+
this.ctx.readFiles.clear();
|
|
185
|
+
}
|
|
186
|
+
async *send(userText) {
|
|
187
|
+
// Auto routing resolves once per user message, not per tool-loop turn: the
|
|
188
|
+
// task's difficulty is set by what was asked, and switching models mid-loop
|
|
189
|
+
// would throw away the provider-side prompt cache that decides margins.
|
|
190
|
+
if (this.modelPreference === AUTO_MODEL) {
|
|
191
|
+
const decision = route({
|
|
192
|
+
text: userText,
|
|
193
|
+
planMode: this.mode === "plan",
|
|
194
|
+
...(this.opts.autoRouter?.bias ? { bias: this.opts.autoRouter.bias } : {}),
|
|
195
|
+
...(this.opts.autoRouter?.catalog ? { available: this.opts.autoRouter.catalog } : {}),
|
|
196
|
+
...(this.opts.subagent ? { subagent: this.opts.subagent } : {}),
|
|
197
|
+
});
|
|
198
|
+
this.model = decision.model;
|
|
199
|
+
yield {
|
|
200
|
+
type: "model_selected",
|
|
201
|
+
model: decision.model,
|
|
202
|
+
complexity: decision.complexity,
|
|
203
|
+
reason: decision.reason,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
this.messages.push({ role: "user", content: userText });
|
|
207
|
+
const maxTurns = this.opts.maxTurnsPerMessage ?? MAX_TURNS_DEFAULT;
|
|
208
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
209
|
+
// Compact between turns, never mid-exchange: at the top of a turn every
|
|
210
|
+
// tool call from the previous one already has its result, so the history
|
|
211
|
+
// is in a state that can be safely cut.
|
|
212
|
+
if (this.opts.autoCompact !== false &&
|
|
213
|
+
!this.compactionIneffective &&
|
|
214
|
+
shouldCompact(this.messages, this.contextLimit)) {
|
|
215
|
+
const result = await this.compact();
|
|
216
|
+
if (result) {
|
|
217
|
+
yield {
|
|
218
|
+
type: "compacted",
|
|
219
|
+
beforeTokens: result.before,
|
|
220
|
+
afterTokens: result.after,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
let result = null;
|
|
225
|
+
try {
|
|
226
|
+
for await (const ev of streamChat({
|
|
227
|
+
apiKey: this.opts.apiKey,
|
|
228
|
+
model: this.model,
|
|
229
|
+
messages: this.messages,
|
|
230
|
+
tools: this.tools.map(toSchema),
|
|
231
|
+
...(this.opts.platform
|
|
232
|
+
? {
|
|
233
|
+
baseUrl: this.opts.platform.baseUrl,
|
|
234
|
+
extraBody: {
|
|
235
|
+
context: {
|
|
236
|
+
cwd: this.opts.cwd,
|
|
237
|
+
platform: process.platform,
|
|
238
|
+
memory: this.memory || undefined,
|
|
239
|
+
sessionId: this.sessionId,
|
|
240
|
+
// Sub-agent burn is attributed to the session but tagged, so
|
|
241
|
+
// calibration can separate main-loop cost from fan-out cost.
|
|
242
|
+
...(this.opts.subagent ? { subagent: this.opts.subagent } : {}),
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
: {}),
|
|
247
|
+
})) {
|
|
248
|
+
if (ev.type === "delta")
|
|
249
|
+
yield { type: "text_delta", text: ev.text };
|
|
250
|
+
else
|
|
251
|
+
result = ev.result;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
yield {
|
|
256
|
+
type: "error",
|
|
257
|
+
message: err instanceof Error ? err.message : String(err),
|
|
258
|
+
...(err instanceof ApiError && err.code ? { code: err.code } : {}),
|
|
259
|
+
};
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!result) {
|
|
263
|
+
yield { type: "error", message: "stream ended without a result" };
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
this.messages.push(result.message);
|
|
267
|
+
this.addUsage(result.usage);
|
|
268
|
+
const toolCalls = result.message.tool_calls ?? [];
|
|
269
|
+
if (!toolCalls.length) {
|
|
270
|
+
yield { type: "turn_end", usage: { ...this.usage } };
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Phase 1, parse + permission-gate sequentially (deciders are interactive).
|
|
274
|
+
const prepared = [];
|
|
275
|
+
for (const raw of toolCalls) {
|
|
276
|
+
let input = {};
|
|
277
|
+
let early;
|
|
278
|
+
try {
|
|
279
|
+
input = raw.function.arguments ? JSON.parse(raw.function.arguments) : {};
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
early = "Error: tool arguments were not valid JSON";
|
|
283
|
+
}
|
|
284
|
+
const call = { id: raw.id, name: raw.function.name, input };
|
|
285
|
+
const tool = this.tools.find((t) => t.name === call.name);
|
|
286
|
+
if (!tool && !early)
|
|
287
|
+
early = `Error: unknown tool "${call.name}"`;
|
|
288
|
+
if (tool && !early) {
|
|
289
|
+
const summary = tool.summarize(call.input);
|
|
290
|
+
const gate = gateFor(tool.kind, this.mode);
|
|
291
|
+
if (gate === "deny") {
|
|
292
|
+
yield { type: "permission_denied", call, summary };
|
|
293
|
+
early = "Error: not permitted in plan (read-only) mode. Present a plan instead.";
|
|
294
|
+
}
|
|
295
|
+
else if (gate === "ask") {
|
|
296
|
+
const allowed = this.opts.decide
|
|
297
|
+
? await this.opts.decide({ tool: call.name, summary, input: call.input })
|
|
298
|
+
: false;
|
|
299
|
+
if (!allowed) {
|
|
300
|
+
yield { type: "permission_denied", call, summary };
|
|
301
|
+
early =
|
|
302
|
+
"Error: the user declined this tool call. Adjust your approach or ask them how to proceed.";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
prepared.push({ call, tool, early });
|
|
307
|
+
}
|
|
308
|
+
// Phase 2, execute. `task` calls run concurrently (sub-agents are independent);
|
|
309
|
+
// everything else runs sequentially to keep fs/shell effects ordered.
|
|
310
|
+
const outputs = new Map();
|
|
311
|
+
const taskBatch = prepared.filter((p) => !p.early && p.tool?.name === "task");
|
|
312
|
+
for (const p of taskBatch) {
|
|
313
|
+
yield { type: "tool_start", call: p.call, summary: p.tool.summarize(p.call.input) };
|
|
314
|
+
}
|
|
315
|
+
const taskResults = await Promise.all(taskBatch.map(async (p) => {
|
|
316
|
+
const started = Date.now();
|
|
317
|
+
try {
|
|
318
|
+
return { p, output: await p.tool.run(p.call.input, this.ctx), isError: false, ms: Date.now() - started };
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
return {
|
|
322
|
+
p,
|
|
323
|
+
output: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
324
|
+
isError: true,
|
|
325
|
+
ms: Date.now() - started,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}));
|
|
329
|
+
for (const r of taskResults) {
|
|
330
|
+
outputs.set(r.p.call.id, r.output);
|
|
331
|
+
yield { type: "tool_end", call: r.p.call, output: r.output, isError: r.isError, durationMs: r.ms };
|
|
332
|
+
}
|
|
333
|
+
for (const p of prepared) {
|
|
334
|
+
if (outputs.has(p.call.id))
|
|
335
|
+
continue;
|
|
336
|
+
if (p.early) {
|
|
337
|
+
outputs.set(p.call.id, p.early);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const tool = p.tool;
|
|
341
|
+
yield { type: "tool_start", call: p.call, summary: tool.summarize(p.call.input) };
|
|
342
|
+
const started = Date.now();
|
|
343
|
+
let output;
|
|
344
|
+
let isError = false;
|
|
345
|
+
try {
|
|
346
|
+
output = await tool.run(p.call.input, this.ctx);
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
output = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
350
|
+
isError = true;
|
|
351
|
+
}
|
|
352
|
+
outputs.set(p.call.id, output);
|
|
353
|
+
yield { type: "tool_end", call: p.call, output, isError, durationMs: Date.now() - started };
|
|
354
|
+
}
|
|
355
|
+
// Tool results append in the model's original call order.
|
|
356
|
+
for (const raw of toolCalls) {
|
|
357
|
+
this.messages.push({
|
|
358
|
+
role: "tool",
|
|
359
|
+
tool_call_id: raw.id,
|
|
360
|
+
content: truncate(outputs.get(raw.id) ?? "Error: no result", MAX_TOOL_RESULT),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
yield { type: "error", message: `stopped after ${maxTurns} tool turns for one message` };
|
|
365
|
+
}
|
|
366
|
+
addUsage(u) {
|
|
367
|
+
this.usage.inputTokens += u.inputTokens;
|
|
368
|
+
this.usage.cachedTokens += u.cachedTokens;
|
|
369
|
+
this.usage.outputTokens += u.outputTokens;
|
|
370
|
+
this.usage.requests += u.requests;
|
|
371
|
+
}
|
|
372
|
+
makeAskUserTool() {
|
|
373
|
+
const askUser = this.opts.askUser;
|
|
374
|
+
return {
|
|
375
|
+
name: "ask_user",
|
|
376
|
+
description: "Ask the user a clarifying question when a decision is genuinely theirs, requirements, preferences, trade-offs. Offer 2-4 options when applicable. Never ask for information your other tools can find.",
|
|
377
|
+
kind: "read", // interactive but harmless; allowed even in plan mode
|
|
378
|
+
parameters: {
|
|
379
|
+
type: "object",
|
|
380
|
+
properties: {
|
|
381
|
+
question: { type: "string" },
|
|
382
|
+
options: { type: "array", items: { type: "string" }, description: "Optional choices" },
|
|
383
|
+
},
|
|
384
|
+
required: ["question"],
|
|
385
|
+
},
|
|
386
|
+
summarize: (i) => `ask: ${String(i.question ?? "").slice(0, 80)}`,
|
|
387
|
+
run: async (input) => {
|
|
388
|
+
const question = String(input.question ?? "");
|
|
389
|
+
const options = Array.isArray(input.options) ? input.options.map(String) : undefined;
|
|
390
|
+
const answer = await askUser({ question, ...(options ? { options } : {}) });
|
|
391
|
+
return `User answered: ${answer}`;
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/** Cheapest model the plan allows, unless the host pinned one. */
|
|
396
|
+
get workerModelId() {
|
|
397
|
+
return (this.opts.delegation?.model ?? workerModel(this.opts.autoRouter?.catalog));
|
|
398
|
+
}
|
|
399
|
+
/** Shared plumbing for both delegating tools. */
|
|
400
|
+
async runDelegation(mode, message) {
|
|
401
|
+
const result = await delegate({
|
|
402
|
+
apiKey: this.opts.apiKey,
|
|
403
|
+
model: this.workerModelId,
|
|
404
|
+
mode,
|
|
405
|
+
message,
|
|
406
|
+
cwd: this.opts.cwd,
|
|
407
|
+
sessionId: this.sessionId,
|
|
408
|
+
...(this.opts.platform ? { platform: this.opts.platform } : {}),
|
|
409
|
+
});
|
|
410
|
+
// Worker burn is real spend and belongs in the session total, exactly as
|
|
411
|
+
// sub-agent burn does. The saving is the corpus that never became context.
|
|
412
|
+
this.addUsage(result.usage);
|
|
413
|
+
this.delegated.calls += 1;
|
|
414
|
+
this.delegated.contextTokensSaved += estimateCorpusTokens(message.length);
|
|
415
|
+
this.delegated.workerUsage.inputTokens += result.usage.inputTokens;
|
|
416
|
+
this.delegated.workerUsage.cachedTokens += result.usage.cachedTokens;
|
|
417
|
+
this.delegated.workerUsage.outputTokens += result.usage.outputTokens;
|
|
418
|
+
this.delegated.workerUsage.requests += result.usage.requests;
|
|
419
|
+
return result.text;
|
|
420
|
+
}
|
|
421
|
+
/** Read the requested paths, failing loudly on any that are missing. */
|
|
422
|
+
async loadFiles(paths) {
|
|
423
|
+
return Promise.all(paths.map(async (raw) => {
|
|
424
|
+
const file = path.resolve(this.opts.cwd, raw);
|
|
425
|
+
try {
|
|
426
|
+
return { path: file, content: await fs.readFile(file, "utf8") };
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// A path that silently became an empty block would produce a
|
|
430
|
+
// confident answer about nothing, which is worse than an error.
|
|
431
|
+
throw new Error(`cannot read ${file}`);
|
|
432
|
+
}
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
makeBulkReadTool() {
|
|
436
|
+
return {
|
|
437
|
+
name: "bulk_read",
|
|
438
|
+
description: "Read files through a cheap worker model and get back only the answer. Use it for any file the read tool refuses, for a question spanning several files, and for large diffs, logs or generated output. The files go to the worker, never into this conversation, so asking a second question about the same paths costs nothing. Ask one specific question per call rather than requesting a summary. Do not use it to prepare an edit (you need exact bytes: read with offset/limit), to debug, or to make a design decision, where the reasoning is the whole job.",
|
|
439
|
+
kind: "read",
|
|
440
|
+
parameters: {
|
|
441
|
+
type: "object",
|
|
442
|
+
properties: {
|
|
443
|
+
question: {
|
|
444
|
+
type: "string",
|
|
445
|
+
description: "One specific question. Ask for path:line citations if you will need to read the section afterwards.",
|
|
446
|
+
},
|
|
447
|
+
paths: {
|
|
448
|
+
type: "array",
|
|
449
|
+
items: { type: "string" },
|
|
450
|
+
description: "Files to send to the worker (absolute or relative to cwd)",
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
required: ["question", "paths"],
|
|
454
|
+
},
|
|
455
|
+
summarize: (i) => {
|
|
456
|
+
const n = Array.isArray(i.paths) ? i.paths.length : 0;
|
|
457
|
+
return `bulk_read ${n} file(s): ${String(i.question ?? "").slice(0, 60)}`;
|
|
458
|
+
},
|
|
459
|
+
run: async (input) => {
|
|
460
|
+
const question = str(input.question);
|
|
461
|
+
const paths = Array.isArray(input.paths) ? input.paths.map(String) : [];
|
|
462
|
+
if (!paths.length)
|
|
463
|
+
throw new Error("paths is required and must be non-empty");
|
|
464
|
+
const files = await this.loadFiles(paths);
|
|
465
|
+
return this.runDelegation("bulk-reader", buildReadCorpus(files, question));
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
makeCodeWriteTool() {
|
|
470
|
+
return {
|
|
471
|
+
name: "code_write",
|
|
472
|
+
description: "Generate predictable code on a cheap worker model, optionally straight to disk. Use it for tests, fixtures, config, migrations, type stubs, docstrings: anything where most of the output follows from a reference file. `reference` is required, because without a file to match the worker invents conventions that fit nothing in the project. Pass the closest existing sibling. Review what it produces and edit the part that needed judgement; the worker matches patterns, not intent.",
|
|
473
|
+
kind: "mutate",
|
|
474
|
+
parameters: {
|
|
475
|
+
type: "object",
|
|
476
|
+
properties: {
|
|
477
|
+
spec: { type: "string", description: "What to generate" },
|
|
478
|
+
reference: {
|
|
479
|
+
type: "array",
|
|
480
|
+
items: { type: "string" },
|
|
481
|
+
description: "Files whose patterns and style the output must match",
|
|
482
|
+
},
|
|
483
|
+
target: {
|
|
484
|
+
type: "string",
|
|
485
|
+
description: "Where to write. Omitted, the code is returned instead.",
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
required: ["spec", "reference"],
|
|
489
|
+
},
|
|
490
|
+
summarize: (i) => `code_write ${i.target ? `→ ${i.target}` : "(stdout)"}: ${String(i.spec ?? "").slice(0, 60)}`,
|
|
491
|
+
run: async (input, ctx) => {
|
|
492
|
+
const spec = str(input.spec);
|
|
493
|
+
const refs = Array.isArray(input.reference) ? input.reference.map(String) : [];
|
|
494
|
+
if (!refs.length) {
|
|
495
|
+
throw new Error("reference is required: pass a file whose patterns the output should match. For a follow-up, reference the file the previous call generated.");
|
|
496
|
+
}
|
|
497
|
+
const references = await this.loadFiles(refs);
|
|
498
|
+
const target = typeof input.target === "string" ? path.resolve(this.opts.cwd, input.target) : null;
|
|
499
|
+
// Same read-before-overwrite rule the write tool enforces: worker output
|
|
500
|
+
// lands unreviewed, so clobbering a file nobody looked at is worse here.
|
|
501
|
+
if (target) {
|
|
502
|
+
const exists = await fs.access(target).then(() => true, () => false);
|
|
503
|
+
if (exists && !ctx.readFiles.has(target)) {
|
|
504
|
+
throw new Error(`${target} exists. Read it before overwriting`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const code = stripFences(await this.runDelegation("code-writer", buildWriteCorpus(spec, references)));
|
|
508
|
+
if (!target)
|
|
509
|
+
return code;
|
|
510
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
511
|
+
await fs.writeFile(target, code + "\n", "utf8");
|
|
512
|
+
ctx.readFiles.add(target);
|
|
513
|
+
return `Wrote ${code.split("\n").length} lines to ${target}. Review it before relying on it.`;
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
makeTaskTool() {
|
|
518
|
+
return {
|
|
519
|
+
name: "task",
|
|
520
|
+
description: 'Spawn a sub-agent with its own fresh context. agent_type "explore" gets read-only tools (codebase investigation, returns findings); "general" gets all tools and runs unattended. Give a complete, self-contained prompt including what to return. Multiple task calls in one response run in parallel.',
|
|
521
|
+
kind: "exec", // spawning an unattended agent ≈ running a command
|
|
522
|
+
parameters: {
|
|
523
|
+
type: "object",
|
|
524
|
+
properties: {
|
|
525
|
+
description: { type: "string", description: "Short label shown to the user" },
|
|
526
|
+
prompt: { type: "string", description: "Complete instructions for the sub-agent" },
|
|
527
|
+
agent_type: { type: "string", enum: ["explore", "general"] },
|
|
528
|
+
model: { type: "string", description: "Override model (default: parent's model)" },
|
|
529
|
+
},
|
|
530
|
+
required: ["description", "prompt", "agent_type"],
|
|
531
|
+
},
|
|
532
|
+
summarize: (i) => `task(${i.agent_type}): ${String(i.description ?? "").slice(0, 80)}`,
|
|
533
|
+
run: async (input) => {
|
|
534
|
+
const agentType = input.agent_type === "explore" ? "explore" : "general";
|
|
535
|
+
// In Auto mode the sub-agent routes for itself, so explore agents drop
|
|
536
|
+
// to economy models instead of inheriting the parent's resolved pick -
|
|
537
|
+
// the big burn saver on parallel sub-agents (PLAN.md §5.5).
|
|
538
|
+
const subModel = typeof input.model === "string"
|
|
539
|
+
? input.model
|
|
540
|
+
: this.isAuto
|
|
541
|
+
? AUTO_MODEL
|
|
542
|
+
: this.model;
|
|
543
|
+
const sub = new AgentSession({
|
|
544
|
+
apiKey: this.opts.apiKey,
|
|
545
|
+
model: subModel,
|
|
546
|
+
cwd: this.opts.cwd,
|
|
547
|
+
// the spawn itself was permission-gated; the sub-agent runs unattended
|
|
548
|
+
mode: "bypass",
|
|
549
|
+
toolset: agentType === "explore" ? "read-only" : "all",
|
|
550
|
+
subagent: agentType,
|
|
551
|
+
sessionId: this.sessionId, // fan-out burn belongs to the parent session
|
|
552
|
+
depth: this.depth + 1,
|
|
553
|
+
maxTurnsPerMessage: 25,
|
|
554
|
+
...(this.opts.platform ? { platform: this.opts.platform } : {}),
|
|
555
|
+
...(this.opts.mcp ? { mcp: this.opts.mcp } : {}),
|
|
556
|
+
...(this.opts.autoRouter ? { autoRouter: this.opts.autoRouter } : {}),
|
|
557
|
+
});
|
|
558
|
+
let text = "";
|
|
559
|
+
let error = null;
|
|
560
|
+
for await (const ev of sub.send(String(input.prompt ?? ""))) {
|
|
561
|
+
if (ev.type === "text_delta")
|
|
562
|
+
text += ev.text;
|
|
563
|
+
if (ev.type === "error")
|
|
564
|
+
error = ev.message;
|
|
565
|
+
}
|
|
566
|
+
this.addUsage(sub.usage); // sub-agent burn rolls up to the parent's totals
|
|
567
|
+
if (error)
|
|
568
|
+
throw new Error(`sub-agent failed: ${error}`);
|
|
569
|
+
return text.trim() || "[sub-agent returned no text]";
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
//# sourceMappingURL=session.js.map
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface Skill {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
body: string;
|
|
5
|
+
source: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Skills: markdown files with optional `--- name/description ---` frontmatter, loaded
|
|
9
|
+
* from ~/.9p/skills and <cwd>/.9p/skills (project wins on name clash). Anti-bloat rule
|
|
10
|
+
* (PLAN.md §4.1): a skill's body enters context only when the user invokes /<name>.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadSkills(cwd: string): Skill[];
|
|
13
|
+
/** The message sent when a user invokes /<skill> [args]. */
|
|
14
|
+
export declare function skillMessage(skill: Skill, args: string): string;
|