@falkenslab/agent-kit 0.33.0 → 0.34.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/dist/chat/chatController.d.ts +10 -0
- package/dist/chat/chatController.js +90 -21
- package/dist/core/runner.d.ts +16 -1
- package/dist/core/runner.js +22 -2
- package/dist/core/runs.d.ts +16 -0
- package/dist/core/runs.js +46 -4
- package/dist/core/session.d.ts +7 -1
- package/dist/core/session.js +10 -5
- package/dist/tui/consoleRenderer.js +1 -0
- package/package.json +1 -1
|
@@ -89,6 +89,8 @@ export interface TranscriptCall {
|
|
|
89
89
|
isError: boolean;
|
|
90
90
|
text: string;
|
|
91
91
|
} | null;
|
|
92
|
+
/** An MCP result's `_meta`, untouched: data for this view, which the model never sees (absent when the tool returned none). */
|
|
93
|
+
meta?: Record<string, unknown>;
|
|
92
94
|
/** What a subagent it started did, by label. */
|
|
93
95
|
children: string[];
|
|
94
96
|
}
|
|
@@ -251,6 +253,14 @@ export declare function createChatController(options: Options | SessionOpener, s
|
|
|
251
253
|
* goes to the agent as it is, never read as one of the chat's commands (`/extensions`, `/resume`).
|
|
252
254
|
*/
|
|
253
255
|
turn(raw: string): Promise<void>;
|
|
256
|
+
/**
|
|
257
|
+
* A message for the agent while a turn runs (#92), without stopping it: it joins the turn at
|
|
258
|
+
* its next tool call, or is answered right after when no call is left, and it stays in the
|
|
259
|
+
* transcript where it was sent. The turn `send()` is waiting for lasts until it's answered.
|
|
260
|
+
* False, and nothing sent, when no turn is running (use `send()`) and for a slash command or
|
|
261
|
+
* an exit line, which wait for the turn to end.
|
|
262
|
+
*/
|
|
263
|
+
steer(raw: string): Promise<boolean>;
|
|
254
264
|
/** Stops the turn running, if any. */
|
|
255
265
|
interrupt(): void;
|
|
256
266
|
/** Switches to a mode the session allows; says so when it can't. */
|
|
@@ -4,7 +4,7 @@ import { getInteractionPort, setInteractionPort } from "../core/interaction.js";
|
|
|
4
4
|
import { getLanguage, switchLanguage, t } from "../core/messages/index.js";
|
|
5
5
|
import { LANGUAGE_NAMES } from "../core/language.js";
|
|
6
6
|
import { runQuery } from "../core/runner.js";
|
|
7
|
-
import { createRunFolder, listRuns,
|
|
7
|
+
import { createRunFolder, listRuns, readConversationSteps, readRunSession } from "../core/runs.js";
|
|
8
8
|
import { createInputQueue, togglePlanMode } from "../core/session.js";
|
|
9
9
|
import { hasOpenTodos, parseTodos, TODO_TOOL } from "../core/todos.js";
|
|
10
10
|
import { withToolLabels, withToolPhrases, withToolResults } from "../core/toolLabels.js";
|
|
@@ -96,15 +96,18 @@ export async function createChatController(options, settings = {}) {
|
|
|
96
96
|
addEntry({ id: nextId++, kind: "notice", tone, text });
|
|
97
97
|
emit({ type: "notice", tone, text });
|
|
98
98
|
}
|
|
99
|
+
// A reply of its own coming: the answer to a message sent into the turn, after a `turn-end`.
|
|
100
|
+
let newReply = false;
|
|
99
101
|
/** What the agent does, into the transcript. */
|
|
100
102
|
function record(event) {
|
|
101
103
|
const last = state.transcript.at(-1);
|
|
102
104
|
switch (event.type) {
|
|
103
105
|
case "text":
|
|
104
|
-
if (last?.kind === "agent")
|
|
106
|
+
if (last?.kind === "agent" && !newReply)
|
|
105
107
|
update({ transcript: [...state.transcript.slice(0, -1), { ...last, text: last.text + event.text }] });
|
|
106
108
|
else
|
|
107
109
|
addEntry({ id: nextId++, kind: "agent", text: event.text });
|
|
110
|
+
newReply = false;
|
|
108
111
|
return;
|
|
109
112
|
case "action": {
|
|
110
113
|
const todos = event.toolName === TODO_TOOL ? parseTodos(event.input) : null;
|
|
@@ -129,7 +132,7 @@ export async function createChatController(options, settings = {}) {
|
|
|
129
132
|
? entry
|
|
130
133
|
: {
|
|
131
134
|
...entry,
|
|
132
|
-
calls: entry.calls.map((call) => call.id !== id ? call : event.type === "tool-result" ? { ...call, result: { isError: event.isError, text: event.text } } : { ...call, children: [...call.children, label] }),
|
|
135
|
+
calls: entry.calls.map((call) => call.id !== id ? call : event.type === "tool-result" ? { ...call, result: { isError: event.isError, text: event.text }, ...(event.meta ? { meta: event.meta } : {}) } : { ...call, children: [...call.children, label] }),
|
|
133
136
|
});
|
|
134
137
|
update({ transcript, ...(label ? { subagentActivity: label } : {}) });
|
|
135
138
|
return;
|
|
@@ -160,9 +163,17 @@ export async function createChatController(options, settings = {}) {
|
|
|
160
163
|
let readError = null;
|
|
161
164
|
let unsubscribeMode;
|
|
162
165
|
let interrupted = false;
|
|
166
|
+
// A message sent into this turn (`steer()`): its `turn-end` may not be the last, so the turn
|
|
167
|
+
// ends at `session-idle`, once a `turn-end` has come after the latest message sent. Before
|
|
168
|
+
// that, an idle is an earlier one, still on its way.
|
|
169
|
+
let steered = false;
|
|
170
|
+
let awaitingIdle = false;
|
|
163
171
|
function finishTurn() {
|
|
164
172
|
const done = turnDone;
|
|
165
173
|
turnDone = null;
|
|
174
|
+
newReply = false;
|
|
175
|
+
steered = false;
|
|
176
|
+
awaitingIdle = false;
|
|
166
177
|
done?.();
|
|
167
178
|
}
|
|
168
179
|
/** The commands the session has, and the chat's own, into the state: when it opens, and when an extension is switched in it. */
|
|
@@ -214,12 +225,25 @@ export async function createChatController(options, settings = {}) {
|
|
|
214
225
|
const { value, done } = await events.next();
|
|
215
226
|
if (own !== generation || done)
|
|
216
227
|
break;
|
|
228
|
+
if (value.type === "session-idle") {
|
|
229
|
+
if (awaitingIdle)
|
|
230
|
+
finishTurn();
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
217
233
|
if (value.type !== "prompt-suggestion")
|
|
218
234
|
logRenderer.render(value);
|
|
219
235
|
record(value);
|
|
220
236
|
emit({ type: "agent", event: value });
|
|
221
|
-
if (value.type === "turn-end")
|
|
222
|
-
|
|
237
|
+
if (value.type === "turn-end") {
|
|
238
|
+
if (!steered)
|
|
239
|
+
finishTurn();
|
|
240
|
+
else {
|
|
241
|
+
awaitingIdle = true;
|
|
242
|
+
newReply = true;
|
|
243
|
+
// What's left to answer can be interrupted too.
|
|
244
|
+
interrupted = false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
223
247
|
}
|
|
224
248
|
}
|
|
225
249
|
catch (error) {
|
|
@@ -238,6 +262,12 @@ export async function createChatController(options, settings = {}) {
|
|
|
238
262
|
// Extensions turned off or on in the running session, told to the model with the next message
|
|
239
263
|
// too: the prompt's Extensions section still says what the session opened with.
|
|
240
264
|
const extensionNotices = [];
|
|
265
|
+
/** A line with what the model hasn't been told yet before it: each note goes once. */
|
|
266
|
+
function withNotices(line) {
|
|
267
|
+
const notices = [languageNotice, ...extensionNotices.splice(0)].filter(Boolean);
|
|
268
|
+
languageNotice = null;
|
|
269
|
+
return notices.length ? `${notices.join("\n")}\n\n${line}` : line;
|
|
270
|
+
}
|
|
241
271
|
async function runTurn(line) {
|
|
242
272
|
interrupted = false;
|
|
243
273
|
const from = state.transcript.length;
|
|
@@ -246,9 +276,7 @@ export async function createChatController(options, settings = {}) {
|
|
|
246
276
|
update({ busy: true, turnStartedAt: startedAt, activity: null, subagentActivity: null, suggestion: null });
|
|
247
277
|
emit({ type: "turn-start" });
|
|
248
278
|
const finished = new Promise((resolve) => (turnDone = resolve));
|
|
249
|
-
|
|
250
|
-
queue.push(notices.length ? `${notices.join("\n")}\n\n${line}` : line);
|
|
251
|
-
languageNotice = null;
|
|
279
|
+
queue.push(withNotices(line));
|
|
252
280
|
await finished;
|
|
253
281
|
logRenderer.endLine();
|
|
254
282
|
latestReply = state.transcript.slice(from).flatMap((entry) => (entry.kind === "agent" ? [entry.text] : [])).join("").trim();
|
|
@@ -264,10 +292,26 @@ export async function createChatController(options, settings = {}) {
|
|
|
264
292
|
async function showConversation(folder, fromResume) {
|
|
265
293
|
if (!folder.sessionId)
|
|
266
294
|
return;
|
|
267
|
-
const
|
|
295
|
+
const steps = await readConversationSteps(folder.dir);
|
|
296
|
+
const messages = steps.filter((step) => step.role !== "tool");
|
|
268
297
|
const [summary] = (await listRuns(path.dirname(folder.dir))).filter((other) => path.resolve(other.dir) === path.resolve(folder.dir));
|
|
269
298
|
const updatedAt = summary?.updatedAt ?? new Date();
|
|
270
|
-
const replayed =
|
|
299
|
+
const replayed = [];
|
|
300
|
+
for (const step of steps) {
|
|
301
|
+
if (step.role !== "tool") {
|
|
302
|
+
replayed.push({ id: nextId++, kind: step.role === "user" ? "user" : "agent", text: step.text });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (step.toolName === TODO_TOOL)
|
|
306
|
+
continue;
|
|
307
|
+
// A call the session ended in the middle of has no result; `null` would read as still running.
|
|
308
|
+
const call = { id: step.id, toolName: step.toolName, label: formatAction(step.toolName, step.input), result: step.result ?? { isError: false, text: "" }, children: [], ...(step.meta ? { meta: step.meta } : {}) };
|
|
309
|
+
const last = replayed.at(-1);
|
|
310
|
+
if (last?.kind === "tools")
|
|
311
|
+
last.calls.push(call);
|
|
312
|
+
else
|
|
313
|
+
replayed.push({ id: nextId++, kind: "tools", calls: [call] });
|
|
314
|
+
}
|
|
271
315
|
update({ transcript: fromResume ? replayed : [...state.transcript, ...replayed], turns: (fromResume ? 0 : state.turns) + messages.filter((message) => message.role === "user").length });
|
|
272
316
|
emit({ type: "conversation", messages, updatedAt, fromResume });
|
|
273
317
|
}
|
|
@@ -364,6 +408,20 @@ export async function createChatController(options, settings = {}) {
|
|
|
364
408
|
}
|
|
365
409
|
await controller.trustWorkspace();
|
|
366
410
|
}
|
|
411
|
+
/** A line of the person's, in the session log, the transcript and to the views. */
|
|
412
|
+
function showLine(line) {
|
|
413
|
+
log(`${promptLabel}${line}\n`);
|
|
414
|
+
addEntry({ id: nextId++, kind: "user", text: line });
|
|
415
|
+
emit({ type: "user", text: line });
|
|
416
|
+
}
|
|
417
|
+
/** A line of the person's, kept for ↑/↓. */
|
|
418
|
+
async function remember(line) {
|
|
419
|
+
if (!settings.historyPath)
|
|
420
|
+
return;
|
|
421
|
+
historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
|
|
422
|
+
update({ history: historyEntries.map((entry) => entry.text) });
|
|
423
|
+
await saveHistory(settings.historyPath, historyEntries);
|
|
424
|
+
}
|
|
367
425
|
// A choice in place of the prompt, when the view doesn't ask it itself.
|
|
368
426
|
let choiceResolve = null;
|
|
369
427
|
function pick(title, options) {
|
|
@@ -455,14 +513,8 @@ export async function createChatController(options, settings = {}) {
|
|
|
455
513
|
const line = raw.trim();
|
|
456
514
|
if (!line)
|
|
457
515
|
return;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
emit({ type: "user", text: line });
|
|
461
|
-
if (settings.historyPath) {
|
|
462
|
-
historyEntries = capHistory([...historyEntries, { text: line, timestamp: new Date().toISOString() }], historyLimit);
|
|
463
|
-
update({ history: historyEntries.map((entry) => entry.text) });
|
|
464
|
-
await saveHistory(settings.historyPath, historyEntries);
|
|
465
|
-
}
|
|
516
|
+
showLine(line);
|
|
517
|
+
await remember(line);
|
|
466
518
|
const lower = line.toLowerCase();
|
|
467
519
|
if (exitCommands.has(lower))
|
|
468
520
|
return "exit";
|
|
@@ -501,11 +553,28 @@ export async function createChatController(options, settings = {}) {
|
|
|
501
553
|
const line = raw.trim();
|
|
502
554
|
if (!line)
|
|
503
555
|
return;
|
|
504
|
-
|
|
505
|
-
addEntry({ id: nextId++, kind: "user", text: line });
|
|
506
|
-
emit({ type: "user", text: line });
|
|
556
|
+
showLine(line);
|
|
507
557
|
await runTurn(line);
|
|
508
558
|
},
|
|
559
|
+
/**
|
|
560
|
+
* A message for the agent while a turn runs (#92), without stopping it: it joins the turn at
|
|
561
|
+
* its next tool call, or is answered right after when no call is left, and it stays in the
|
|
562
|
+
* transcript where it was sent. The turn `send()` is waiting for lasts until it's answered.
|
|
563
|
+
* False, and nothing sent, when no turn is running (use `send()`) and for a slash command or
|
|
564
|
+
* an exit line, which wait for the turn to end.
|
|
565
|
+
*/
|
|
566
|
+
async steer(raw) {
|
|
567
|
+
const line = raw.trim();
|
|
568
|
+
if (!line || !turnDone || exitCommands.has(line.toLowerCase()) || slashCommandToken(line))
|
|
569
|
+
return false;
|
|
570
|
+
showLine(line);
|
|
571
|
+
queue.push(withNotices(line), { priority: "next" });
|
|
572
|
+
steered = true;
|
|
573
|
+
// Any idle on its way is from before this message.
|
|
574
|
+
awaitingIdle = false;
|
|
575
|
+
await remember(line);
|
|
576
|
+
return true;
|
|
577
|
+
},
|
|
509
578
|
/** Stops the turn running, if any. */
|
|
510
579
|
interrupt() {
|
|
511
580
|
if (!state.busy || interrupted)
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -43,13 +43,16 @@ export type AgentEvent =
|
|
|
43
43
|
}
|
|
44
44
|
/** What one of the main agent's tool calls returned, paired with its `action` by
|
|
45
45
|
* `toolUseId`, as plain text (images and other non-text parts left out). Subagents'
|
|
46
|
-
* results stay out, as their actions do. A console log ignores it.
|
|
46
|
+
* results stay out, as their actions do. A console log ignores it. `meta` is an MCP
|
|
47
|
+
* result's `_meta`, untouched: data for the client, which the model never sees and the kit
|
|
48
|
+
* doesn't interpret (absent when the tool returned none). */
|
|
47
49
|
| {
|
|
48
50
|
type: "tool-result";
|
|
49
51
|
toolUseId: string;
|
|
50
52
|
toolName: string;
|
|
51
53
|
isError: boolean;
|
|
52
54
|
text: string;
|
|
55
|
+
meta?: Record<string, unknown>;
|
|
53
56
|
}
|
|
54
57
|
/** One or more MCP servers failed to connect at session startup. */
|
|
55
58
|
| {
|
|
@@ -88,6 +91,12 @@ export type AgentEvent =
|
|
|
88
91
|
| {
|
|
89
92
|
type: "prompt-suggestion";
|
|
90
93
|
suggestion: string;
|
|
94
|
+
}
|
|
95
|
+
/** Nothing is left to answer. It arrives once, after the last `turn-end`: a message sent
|
|
96
|
+
* into a running turn with no tool call left gets a `turn-end` of its own, so a `turn-end`
|
|
97
|
+
* doesn't say the work is over, and this does (confirmed empirically, SDK 0.3.300). */
|
|
98
|
+
| {
|
|
99
|
+
type: "session-idle";
|
|
91
100
|
};
|
|
92
101
|
/**
|
|
93
102
|
* Running totals for the whole `query()` session so far, not for one turn: the SDK's own
|
|
@@ -114,6 +123,12 @@ export interface SessionUsage {
|
|
|
114
123
|
export declare function visibleErrors(errors: readonly string[]): string[];
|
|
115
124
|
/** A tool result's content as plain text: its text parts joined, anything else skipped. */
|
|
116
125
|
export declare function toolResultText(content: unknown): string;
|
|
126
|
+
/**
|
|
127
|
+
* An MCP result's `_meta`, from what holds it: a stream message's `tool_use_result` or a stored
|
|
128
|
+
* entry's `mcpMeta` (confirmed empirically, SDK 0.3.300: neither `tool_result` nor a
|
|
129
|
+
* `PostToolUse` hook carries it).
|
|
130
|
+
*/
|
|
131
|
+
export declare function toolResultMeta(holder: unknown): Record<string, unknown> | undefined;
|
|
117
132
|
/** How full the session's context window is (see `AgentRun.contextUsage()`). */
|
|
118
133
|
export interface ContextUsage {
|
|
119
134
|
/** 0-100. */
|
package/dist/core/runner.js
CHANGED
|
@@ -19,6 +19,17 @@ export function toolResultText(content) {
|
|
|
19
19
|
.map((part) => part.text)
|
|
20
20
|
.join("\n");
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* An MCP result's `_meta`, from what holds it: a stream message's `tool_use_result` or a stored
|
|
24
|
+
* entry's `mcpMeta` (confirmed empirically, SDK 0.3.300: neither `tool_result` nor a
|
|
25
|
+
* `PostToolUse` hook carries it).
|
|
26
|
+
*/
|
|
27
|
+
export function toolResultMeta(holder) {
|
|
28
|
+
if (typeof holder !== "object" || holder === null)
|
|
29
|
+
return undefined;
|
|
30
|
+
const meta = holder._meta;
|
|
31
|
+
return typeof meta === "object" && meta !== null && !Array.isArray(meta) ? meta : undefined;
|
|
32
|
+
}
|
|
22
33
|
/**
|
|
23
34
|
* The SDK's sign-in methods for a remote MCP server behind OAuth, when its `Query` has them: they
|
|
24
35
|
* aren't in its typings (confirmed empirically, SDK 0.3.300; ADR-028), so each is checked first.
|
|
@@ -66,7 +77,9 @@ async function restoreSavedSwitches(result, options) {
|
|
|
66
77
|
* `close`, `supportedCommands`) is passed through unchanged.
|
|
67
78
|
*/
|
|
68
79
|
export function runQuery(prompt, options) {
|
|
69
|
-
|
|
80
|
+
// The session's state, for `session-idle`: the CLI only reports it when asked (confirmed
|
|
81
|
+
// empirically, SDK 0.3.300). `env` replaces the subprocess environment, so the rest goes on.
|
|
82
|
+
const result = query({ prompt, options: { ...options, env: { ...(options.env ?? process.env), CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" } } });
|
|
70
83
|
// What the session is, for an extension that asks (sessionFacts.ts): the controls now, the
|
|
71
84
|
// tools and skills when the session starts.
|
|
72
85
|
const view = sessionViewOf(options);
|
|
@@ -95,6 +108,11 @@ export function runQuery(prompt, options) {
|
|
|
95
108
|
yield { type: "info", text: message.content, level: message.level };
|
|
96
109
|
continue;
|
|
97
110
|
}
|
|
111
|
+
if (message.type === "system" && message.subtype === "session_state_changed") {
|
|
112
|
+
if (message.state === "idle")
|
|
113
|
+
yield { type: "session-idle" };
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
98
116
|
if (message.type === "system" && message.subtype === "local_command_output") {
|
|
99
117
|
yield { type: "info", text: message.content, level: "local-command" };
|
|
100
118
|
continue;
|
|
@@ -124,13 +142,15 @@ export function runQuery(prompt, options) {
|
|
|
124
142
|
continue;
|
|
125
143
|
}
|
|
126
144
|
if (message.type === "user" && message.parent_tool_use_id === null && Array.isArray(message.message.content)) {
|
|
145
|
+
// One result per message in practice; were there several, `tool_use_result` couldn't be told apart.
|
|
146
|
+
const meta = message.message.content.filter((block) => block.type === "tool_result").length === 1 ? toolResultMeta(message.tool_use_result) : undefined;
|
|
127
147
|
for (const block of message.message.content) {
|
|
128
148
|
if (block.type !== "tool_result")
|
|
129
149
|
continue;
|
|
130
150
|
const toolName = toolNames.get(block.tool_use_id);
|
|
131
151
|
if (!toolName)
|
|
132
152
|
continue;
|
|
133
|
-
yield { type: "tool-result", toolUseId: block.tool_use_id, toolName, isError: block.is_error === true, text: toolResultText(block.content) };
|
|
153
|
+
yield { type: "tool-result", toolUseId: block.tool_use_id, toolName, isError: block.is_error === true, text: toolResultText(block.content), ...(meta ? { meta } : {}) };
|
|
134
154
|
}
|
|
135
155
|
}
|
|
136
156
|
if (message.type === "prompt_suggestion") {
|
package/dist/core/runs.d.ts
CHANGED
|
@@ -23,6 +23,20 @@ export interface ConversationMessage {
|
|
|
23
23
|
role: "user" | "assistant";
|
|
24
24
|
text: string;
|
|
25
25
|
}
|
|
26
|
+
/** One tool call of the main agent's in a kept conversation, with what came back. */
|
|
27
|
+
export interface ConversationToolCall {
|
|
28
|
+
role: "tool";
|
|
29
|
+
id: string;
|
|
30
|
+
toolName: string;
|
|
31
|
+
input: unknown;
|
|
32
|
+
/** Null when the session ended before the result. */
|
|
33
|
+
result: {
|
|
34
|
+
isError: boolean;
|
|
35
|
+
text: string;
|
|
36
|
+
} | null;
|
|
37
|
+
/** An MCP result's `_meta`, as the live `tool-result` event has it. */
|
|
38
|
+
meta?: Record<string, unknown>;
|
|
39
|
+
}
|
|
26
40
|
/** A folder name for a new run, e.g. "2026-09-09T16-50-12-345Z" (sorts by date). */
|
|
27
41
|
export declare function runFolderName(date?: Date): string;
|
|
28
42
|
/** Creates a new, empty run folder under `runsDir`. */
|
|
@@ -47,6 +61,8 @@ export declare function createRunStore(dir: string): SessionStore;
|
|
|
47
61
|
export declare function entryText(entry: SessionStoreEntry): ConversationMessage | null;
|
|
48
62
|
/** The human's messages and the agent's replies in a run's conversation, in order. */
|
|
49
63
|
export declare function readConversation(dir: string): Promise<ConversationMessage[]>;
|
|
64
|
+
/** A run's conversation with the main agent's tool calls in their place: what a view shows of a resumed one. */
|
|
65
|
+
export declare function readConversationSteps(dir: string): Promise<(ConversationMessage | ConversationToolCall)[]>;
|
|
50
66
|
/** The runs under `runsDir` that kept a conversation, newest first; runs without `session.json` are left out. */
|
|
51
67
|
export declare function listRuns(runsDir: string): Promise<RunSummary[]>;
|
|
52
68
|
/** `--continue` among the process's arguments: start by resuming the latest run. */
|
package/dist/core/runs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { toolResultMeta, toolResultText } from "./runner.js";
|
|
3
4
|
const CONVERSATION = "conversation.jsonl";
|
|
4
5
|
const SUBAGENTS = "subagents";
|
|
5
6
|
const SESSION = "session.json";
|
|
@@ -133,6 +134,13 @@ export function createRunStore(dir) {
|
|
|
133
134
|
}
|
|
134
135
|
/** A transcript entry's text when it's something the human wrote or the agent replied, or null. */
|
|
135
136
|
export function entryText(entry) {
|
|
137
|
+
// A message sent into a running turn is kept as a queued command, not as a user entry
|
|
138
|
+
// (confirmed empirically, SDK 0.3.300).
|
|
139
|
+
const queued = entry.type === "attachment" ? entry.attachment : undefined;
|
|
140
|
+
if (queued?.type === "queued_command" && queued.commandMode === "prompt" && typeof queued.prompt === "string") {
|
|
141
|
+
const text = humanText(queued.prompt);
|
|
142
|
+
return text ? { role: "user", text } : null;
|
|
143
|
+
}
|
|
136
144
|
if (entry.type !== "user" && entry.type !== "assistant")
|
|
137
145
|
return null;
|
|
138
146
|
if (entry.isMeta || entry.isSidechain)
|
|
@@ -151,18 +159,52 @@ export function entryText(entry) {
|
|
|
151
159
|
.map((part) => part.text ?? "")
|
|
152
160
|
.join("");
|
|
153
161
|
}
|
|
162
|
+
const shown = humanText(text);
|
|
163
|
+
return shown ? { role: entry.type, text: shown } : null;
|
|
164
|
+
}
|
|
165
|
+
/** A message's text as a person reads it, or null when nothing of it is theirs. */
|
|
166
|
+
function humanText(raw) {
|
|
154
167
|
// The kit's own notes to the model (a plan-mode switch) go before the human's words.
|
|
155
|
-
text =
|
|
168
|
+
const text = raw.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, "");
|
|
156
169
|
// Slash commands and the CLI's own notes arrive as tagged text, not as the human's words.
|
|
157
|
-
|
|
158
|
-
return null;
|
|
159
|
-
return { role: entry.type, text };
|
|
170
|
+
return !text.trim() || /^\s*<[a-z-]+>/.test(text) ? null : text;
|
|
160
171
|
}
|
|
161
172
|
/** The human's messages and the agent's replies in a run's conversation, in order. */
|
|
162
173
|
export async function readConversation(dir) {
|
|
163
174
|
const entries = (await readEntries(path.join(dir, CONVERSATION))) ?? [];
|
|
164
175
|
return entries.map(entryText).filter((message) => message !== null);
|
|
165
176
|
}
|
|
177
|
+
/** A run's conversation with the main agent's tool calls in their place: what a view shows of a resumed one. */
|
|
178
|
+
export async function readConversationSteps(dir) {
|
|
179
|
+
const entries = (await readEntries(path.join(dir, CONVERSATION))) ?? [];
|
|
180
|
+
const steps = [];
|
|
181
|
+
const calls = new Map();
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
const message = entryText(entry);
|
|
184
|
+
if (message)
|
|
185
|
+
steps.push(message);
|
|
186
|
+
const content = entry.message?.content;
|
|
187
|
+
if (entry.isSidechain || !Array.isArray(content))
|
|
188
|
+
continue;
|
|
189
|
+
for (const part of content) {
|
|
190
|
+
if (entry.type === "assistant" && part.type === "tool_use" && part.id && part.name && !calls.has(part.id)) {
|
|
191
|
+
const call = { role: "tool", id: part.id, toolName: part.name, input: part.input, result: null };
|
|
192
|
+
calls.set(part.id, call);
|
|
193
|
+
steps.push(call);
|
|
194
|
+
}
|
|
195
|
+
else if (entry.type === "user" && part.type === "tool_result" && part.tool_use_id) {
|
|
196
|
+
const call = calls.get(part.tool_use_id);
|
|
197
|
+
if (!call)
|
|
198
|
+
continue;
|
|
199
|
+
call.result = { isError: part.is_error === true, text: toolResultText(part.content) };
|
|
200
|
+
const meta = toolResultMeta(entry.mcpMeta);
|
|
201
|
+
if (meta)
|
|
202
|
+
call.meta = meta;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return steps;
|
|
207
|
+
}
|
|
166
208
|
/** The runs under `runsDir` that kept a conversation, newest first; runs without `session.json` are left out. */
|
|
167
209
|
export async function listRuns(runsDir) {
|
|
168
210
|
let names;
|
package/dist/core/session.d.ts
CHANGED
|
@@ -118,11 +118,17 @@ export declare function pluginSkills(roots: readonly string[]): Promise<string[]
|
|
|
118
118
|
*
|
|
119
119
|
* With `modeControl`, each message goes after the note on a plan-mode switch the model
|
|
120
120
|
* hasn't been told about yet (`ModeControl.takeNotice()`).
|
|
121
|
+
*
|
|
122
|
+
* `push(text, { priority: "next" })` is for a turn already running: the message joins it at its
|
|
123
|
+
* next tool boundary without stopping it, or gets a turn of its own when no tool call is left
|
|
124
|
+
* (confirmed empirically, SDK 0.3.300; `"now"` would cut the running tool).
|
|
121
125
|
*/
|
|
122
126
|
export declare function createInputQueue(options?: {
|
|
123
127
|
modeControl?: ModeControl;
|
|
124
128
|
}): {
|
|
125
|
-
push: (text: string
|
|
129
|
+
push: (text: string, delivery?: {
|
|
130
|
+
priority?: "next";
|
|
131
|
+
}) => void;
|
|
126
132
|
end: () => void;
|
|
127
133
|
iterable: AsyncIterable<SDKUserMessage>;
|
|
128
134
|
};
|
package/dist/core/session.js
CHANGED
|
@@ -609,6 +609,10 @@ async function projectSkills(projectDir) {
|
|
|
609
609
|
*
|
|
610
610
|
* With `modeControl`, each message goes after the note on a plan-mode switch the model
|
|
611
611
|
* hasn't been told about yet (`ModeControl.takeNotice()`).
|
|
612
|
+
*
|
|
613
|
+
* `push(text, { priority: "next" })` is for a turn already running: the message joins it at its
|
|
614
|
+
* next tool boundary without stopping it, or gets a turn of its own when no tool call is left
|
|
615
|
+
* (confirmed empirically, SDK 0.3.300; `"now"` would cut the running tool).
|
|
612
616
|
*/
|
|
613
617
|
export function createInputQueue(options = {}) {
|
|
614
618
|
const pending = [];
|
|
@@ -616,8 +620,8 @@ export function createInputQueue(options = {}) {
|
|
|
616
620
|
let ended = false;
|
|
617
621
|
async function* generator() {
|
|
618
622
|
while (!ended) {
|
|
619
|
-
const
|
|
620
|
-
if (
|
|
623
|
+
const next = pending.shift();
|
|
624
|
+
if (next === undefined) {
|
|
621
625
|
await new Promise((resolve) => {
|
|
622
626
|
wake = resolve;
|
|
623
627
|
});
|
|
@@ -625,15 +629,16 @@ export function createInputQueue(options = {}) {
|
|
|
625
629
|
}
|
|
626
630
|
yield {
|
|
627
631
|
type: "user",
|
|
628
|
-
message: { role: "user", content: text },
|
|
632
|
+
message: { role: "user", content: next.text },
|
|
629
633
|
parent_tool_use_id: null,
|
|
634
|
+
...(next.priority ? { priority: next.priority } : {}),
|
|
630
635
|
};
|
|
631
636
|
}
|
|
632
637
|
}
|
|
633
638
|
return {
|
|
634
|
-
push(text) {
|
|
639
|
+
push(text, delivery = {}) {
|
|
635
640
|
const notice = options.modeControl?.takeNotice?.();
|
|
636
|
-
pending.push(notice ? `${notice}\n\n${text}` : text);
|
|
641
|
+
pending.push({ text: notice ? `${notice}\n\n${text}` : text, ...delivery });
|
|
637
642
|
wake?.();
|
|
638
643
|
wake = null;
|
|
639
644
|
},
|