@tea-agent/loop-agent 0.22.0 → 0.23.1

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.
Files changed (73) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +7 -4
  4. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  5. package/dist/application/dag/generate-task-dag.js +17 -3
  6. package/dist/cli/command-definitions.js +8 -7
  7. package/dist/cli/program.js +17 -15
  8. package/dist/commands/doctor.js +269 -18
  9. package/dist/commands/init.js +101 -86
  10. package/dist/commands/stats.js +40 -11
  11. package/dist/shared/operator/capabilities.js +11 -1
  12. package/dist/worker/console/app-data.js +4 -0
  13. package/dist/worker/console/chat/instruction-skills.js +217 -0
  14. package/dist/worker/console/chat/model-resolver.js +106 -0
  15. package/dist/worker/console/chat/pi-runtime.js +605 -0
  16. package/dist/worker/console/chat/resource-loader.js +66 -0
  17. package/dist/worker/console/chat/routes.js +357 -0
  18. package/dist/worker/console/chat/session-store.js +238 -0
  19. package/dist/worker/console/chat/tool-adapter.js +238 -0
  20. package/dist/worker/console/chat/tools.js +171 -0
  21. package/dist/worker/console/server.js +55 -0
  22. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +25 -0
  23. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +1 -0
  24. package/dist/worker/console/static/index.html +2 -2
  25. package/dist/worker/feature/profile-schema.js +1 -1
  26. package/dist/workflows/dag/init-hybrid.js +69 -21
  27. package/dist/workflows/dag/node-execution.js +38 -1
  28. package/dist/workflows/dag/output-protocol.js +89 -0
  29. package/dist/workflows/dag/prompt.js +35 -1
  30. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  31. package/dist/workflows/dag/report.js +28 -1
  32. package/dist/workflows/dag/rerun-task.js +1 -1
  33. package/dist/workflows/dag/scheduler.js +9 -0
  34. package/dist/workflows/dag/types.js +12 -0
  35. package/dist/workflows/dag/validate.js +55 -0
  36. package/docs/README.md +73 -156
  37. package/docs/architecture/README.md +3 -2
  38. package/docs/architecture/dag-execution.md +2 -2
  39. package/docs/architecture/evolution.md +14 -12
  40. package/docs/architecture/system-overview.md +1 -1
  41. package/docs/architecture/worker-and-feature.md +3 -3
  42. package/docs/governance/README.md +15 -0
  43. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  44. package/docs/init-surface.manifest.json +22 -4
  45. package/docs/operations/README.md +12 -0
  46. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  47. package/docs/skills/vetted-skill-registry.md +23 -3
  48. package/docs/templates/README.md +55 -0
  49. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  50. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  51. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  52. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  53. package/docs/templates/hybrid-dag.json +1 -1
  54. package/docs/templates/progress-log.md +9 -2
  55. package/harness.json +5 -5
  56. package/package.json +5 -5
  57. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  58. package/skills/agent-worker/SKILL.md +1 -1
  59. package/skills/grill-with-docs/SKILL.md +44 -52
  60. package/skills/grill-with-docs/adr-format.md +37 -26
  61. package/skills/grill-with-docs/context-format.md +18 -26
  62. package/skills/loop-agent/SKILL.md +28 -112
  63. package/skills/loop-agent/references/command-reference.md +9 -3
  64. package/skills/loop-agent/references/harness-policy.md +3 -3
  65. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  66. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  67. package/skills/loop-agent/references/task-workflow.md +2 -0
  68. package/skills/systematic-debugging/SKILL.md +20 -4
  69. package/skills/test-driven-development/SKILL.md +10 -3
  70. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  71. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  72. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  73. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Operator Chat — HTTP route handlers (plan W7 / W8 backend).
3
+ *
4
+ * Routes (all under /api/operator/v1/chat):
5
+ * POST /sessions — create a new Chat session
6
+ * GET /sessions/:id — fetch session metadata + messages
7
+ * POST /sessions/:id/prompt — SSE stream: send a prompt, stream events
8
+ * GET /sessions/:id/events — SSE stream: (re)connect to live events
9
+ * GET /capabilities — Chat tool surface + instruction skills
10
+ * DELETE /sessions/:id — dispose a Chat session
11
+ *
12
+ * Mutation routes go through the Console mutation gate (boot token + Origin).
13
+ * Read routes (GET capabilities / sessions) are open to loopback.
14
+ */
15
+ import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
16
+ import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, composeInstructionSkillsPrompt, loadOperatorChatInstructionSkills, } from "./instruction-skills.js";
17
+ import { buildOperatorChatToolSchemas } from "./tool-adapter.js";
18
+ import { evaluateMutationGate, isMutationMethod, } from "../security.js";
19
+ import { sendJson } from "../routes.js";
20
+ import { openSseResponse, writeSseEvent, } from "../operation-sse.js";
21
+ function writeChatSse(res, event) {
22
+ res.write(`event: ${event.kind}\n`);
23
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
24
+ }
25
+ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
26
+ const chunks = [];
27
+ let total = 0;
28
+ for await (const chunk of req) {
29
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
30
+ total += buf.length;
31
+ if (total > maxBytes) {
32
+ throw new Error(`request body exceeds ${maxBytes} bytes`);
33
+ }
34
+ chunks.push(buf);
35
+ }
36
+ if (chunks.length === 0)
37
+ return {};
38
+ const raw = Buffer.concat(chunks).toString("utf8");
39
+ if (!raw.trim())
40
+ return {};
41
+ return JSON.parse(raw);
42
+ }
43
+ function gateMutation(req, deps) {
44
+ const failure = evaluateMutationGate(req, {
45
+ bootToken: deps.bootToken.value,
46
+ confirmationToken: deps.bootToken.confirmationToken,
47
+ consoleOrigin: deps.getConsoleOrigin(),
48
+ allowNonLoopbackAccess: deps.allowNonLoopbackAccess,
49
+ });
50
+ if (failure) {
51
+ return {
52
+ ok: false,
53
+ status: failure.status,
54
+ code: failure.code,
55
+ message: failure.message,
56
+ };
57
+ }
58
+ return { ok: true };
59
+ }
60
+ export async function handleChatCapabilities(_req, res, deps) {
61
+ const toolSchemas = buildOperatorChatToolSchemas();
62
+ const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
63
+ sendJson(res, 200, {
64
+ ok: true,
65
+ schemaVersion: 1,
66
+ allowedTools: OPERATOR_CHAT_ALLOWED_TOOLS,
67
+ deniedTools: OPERATOR_CHAT_DENIED_TOOLS,
68
+ deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
69
+ tools: toolSchemas,
70
+ instructionSkills: {
71
+ allowed: OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS,
72
+ loaded: skills.loaded.map((s) => ({
73
+ name: s.name,
74
+ description: s.description,
75
+ charCount: s.charCount,
76
+ })),
77
+ failures: skills.failures,
78
+ skipped: skills.skipped,
79
+ promptFragmentCharCount: composeInstructionSkillsPrompt(skills.loaded).length,
80
+ },
81
+ // bash is ALLOWED since the 2026-07-25 widening (read/explore tools
82
+ // enabled). File-WRITING tools (edit/write/apply_patch) stay excluded.
83
+ hasBash: true,
84
+ });
85
+ }
86
+ export async function handleCreateChatSession(req, res, deps) {
87
+ const gate = gateMutation(req, deps);
88
+ if (!gate.ok) {
89
+ sendJson(res, gate.status, {
90
+ ok: false,
91
+ error: { code: gate.code, message: gate.message },
92
+ });
93
+ return;
94
+ }
95
+ let body;
96
+ try {
97
+ body = await readJsonBody(req);
98
+ }
99
+ catch (error) {
100
+ sendJson(res, 400, {
101
+ ok: false,
102
+ error: {
103
+ code: "INVALID_INPUT",
104
+ message: error instanceof Error ? error.message : String(error),
105
+ },
106
+ });
107
+ return;
108
+ }
109
+ const provider = typeof body.modelProvider === "string" ? body.modelProvider : undefined;
110
+ const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
111
+ try {
112
+ const handle = await deps.runtime.createSession({
113
+ ...(provider && modelId ? { model: { provider, modelId } } : {}),
114
+ });
115
+ await deps.store.create({
116
+ sessionId: handle.sessionId,
117
+ modelProvider: handle.model?.provider,
118
+ modelId: handle.model?.modelId,
119
+ });
120
+ sendJson(res, 201, {
121
+ ok: true,
122
+ sessionId: handle.sessionId,
123
+ sessionFile: handle.sessionFile,
124
+ model: handle.model,
125
+ activeTools: handle.activeTools,
126
+ hasBash: true,
127
+ });
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ sendJson(res, 500, {
132
+ ok: false,
133
+ error: { code: "CHAT_SESSION_CREATE_FAILED", message },
134
+ });
135
+ }
136
+ }
137
+ export async function handleGetChatSession(_req, res, deps, sessionId) {
138
+ const record = await deps.store.get(sessionId);
139
+ if (!record) {
140
+ sendJson(res, 404, {
141
+ ok: false,
142
+ error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` },
143
+ });
144
+ return;
145
+ }
146
+ sendJson(res, 200, { ok: true, session: record, hasBash: true });
147
+ }
148
+ export async function handleChatPrompt(req, res, deps, sessionId) {
149
+ const gate = gateMutation(req, deps);
150
+ if (!gate.ok) {
151
+ sendJson(res, gate.status, {
152
+ ok: false,
153
+ error: { code: gate.code, message: gate.message },
154
+ });
155
+ return;
156
+ }
157
+ let body;
158
+ try {
159
+ body = await readJsonBody(req);
160
+ }
161
+ catch (error) {
162
+ sendJson(res, 400, {
163
+ ok: false,
164
+ error: {
165
+ code: "INVALID_INPUT",
166
+ message: error instanceof Error ? error.message : String(error),
167
+ },
168
+ });
169
+ return;
170
+ }
171
+ const text = typeof body.text === "string" ? body.text.trim() : "";
172
+ if (!text) {
173
+ sendJson(res, 400, {
174
+ ok: false,
175
+ error: { code: "INVALID_INPUT", message: "text is required" },
176
+ });
177
+ return;
178
+ }
179
+ if (!deps.runtime.hasSession(sessionId)) {
180
+ sendJson(res, 404, {
181
+ ok: false,
182
+ error: { code: "NOT_FOUND", message: `chat session not active: ${sessionId}` },
183
+ });
184
+ return;
185
+ }
186
+ const actionContext = deps.runtime.actionContext;
187
+ if (!actionContext) {
188
+ sendJson(res, 503, {
189
+ ok: false,
190
+ error: {
191
+ code: "CHAT_NO_ACTION_CONTEXT",
192
+ message: "Chat runtime has no operator action context wired",
193
+ },
194
+ });
195
+ return;
196
+ }
197
+ // Stream events via SSE.
198
+ openSseResponse(res);
199
+ // Emit an immediate agent_start so the client shows a streaming state
200
+ // before the SDK emits its own agent_start (which we drop below to avoid
201
+ // a duplicate frame — P1.4).
202
+ writeChatSse(res, { kind: "agent_start", sessionId });
203
+ const ac = new AbortController();
204
+ let closed = false;
205
+ req.on("close", () => {
206
+ closed = true;
207
+ ac.abort();
208
+ });
209
+ // Lazily import to avoid circular module load.
210
+ const { runChatTurn } = await import("./session-store.js");
211
+ try {
212
+ await runChatTurn({
213
+ runtime: deps.runtime,
214
+ store: deps.store,
215
+ actionContext,
216
+ sessionId,
217
+ text,
218
+ signal: ac.signal,
219
+ onEvent: (event) => {
220
+ if (closed)
221
+ return;
222
+ // agent_start was already emitted above for immediate client feedback;
223
+ // drop the SDK's duplicate to avoid a double frame (P1.4).
224
+ if (event.type === "agent_start")
225
+ return;
226
+ // heartbeat is a keep-alive: write it as an SSE comment frame (":\n\n")
227
+ // which proxies/browsers treat as traffic but clients ignore as data.
228
+ if (event.type === "heartbeat") {
229
+ if (!res.writableEnded)
230
+ res.write(": heartbeat\n\n");
231
+ return;
232
+ }
233
+ const sse = event.type === "tool_call"
234
+ ? {
235
+ kind: "tool_call",
236
+ sessionId,
237
+ toolCallId: event.toolCallId,
238
+ toolName: event.toolName,
239
+ args: event.args,
240
+ }
241
+ : event.type === "tool_result"
242
+ ? {
243
+ kind: "tool_result",
244
+ sessionId,
245
+ toolCallId: event.toolCallId,
246
+ toolName: event.toolName,
247
+ result: event.result,
248
+ isError: event.isError,
249
+ }
250
+ : event.type === "error"
251
+ ? { kind: "error", sessionId, message: event.message }
252
+ : event.type === "agent_end"
253
+ ? { kind: "agent_end", sessionId, willRetry: event.willRetry }
254
+ : event.type === "agent_settled"
255
+ ? { kind: "agent_settled", sessionId }
256
+ : {
257
+ kind: event.type,
258
+ sessionId,
259
+ text: "text" in event ? event.text : "",
260
+ };
261
+ writeChatSse(res, sse);
262
+ },
263
+ });
264
+ }
265
+ catch (error) {
266
+ // runChatTurn should not reject (it catches runtime.prompt failures
267
+ // internally and returns {ok:false}), but a pre-prompt persistence failure
268
+ // (e.g. store.appendMessage for the user message) CAN throw after the SSE
269
+ // stream is already open. Surface it as an SSE error event so the client
270
+ // sees the failure instead of a hanging connection.
271
+ if (!closed) {
272
+ writeChatSse(res, {
273
+ kind: "error",
274
+ sessionId,
275
+ message: error instanceof Error ? error.message : String(error),
276
+ });
277
+ }
278
+ }
279
+ finally {
280
+ // Guarantee the SSE stream is terminated regardless of outcome so the
281
+ // connection never hangs (regression: previously a rejected promise left
282
+ // res open and the client waited indefinitely).
283
+ if (!closed && !res.writableEnded) {
284
+ res.end();
285
+ }
286
+ }
287
+ }
288
+ export async function handleDeleteChatSession(req, res, deps, sessionId) {
289
+ const gate = gateMutation(req, deps);
290
+ if (!gate.ok) {
291
+ sendJson(res, gate.status, {
292
+ ok: false,
293
+ error: { code: gate.code, message: gate.message },
294
+ });
295
+ return;
296
+ }
297
+ deps.runtime.dispose(sessionId);
298
+ sendJson(res, 200, { ok: true, sessionId, disposed: true });
299
+ }
300
+ /**
301
+ * Dispatch a chat HTTP request. Returns true if handled.
302
+ * Mounted under /api/operator/v1/chat/*.
303
+ */
304
+ export async function handleChatRequest(req, res, deps, pathname) {
305
+ if (!deps)
306
+ return false;
307
+ if (!pathname.startsWith("/api/operator/v1/chat/"))
308
+ return false;
309
+ const method = (req.method ?? "GET").toUpperCase();
310
+ const url = new URL(req.url ?? "/", "http://localhost");
311
+ if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
312
+ await handleChatCapabilities(req, res, deps);
313
+ return true;
314
+ }
315
+ const sessionPrompt = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/prompt$/);
316
+ if (method === "POST" && sessionPrompt) {
317
+ await handleChatPrompt(req, res, deps, decodeURIComponent(sessionPrompt[1]));
318
+ return true;
319
+ }
320
+ const sessionEvents = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/events$/);
321
+ if (method === "GET" && sessionEvents) {
322
+ // MVP: events are delivered inline during prompt SSE. A separate GET
323
+ // events stream is reserved for degraded-state recovery (V13); for now
324
+ // we return the persisted session snapshot so a reconnecting client can
325
+ // re-render history.
326
+ await handleGetChatSession(req, res, deps, decodeURIComponent(sessionEvents[1]));
327
+ return true;
328
+ }
329
+ const sessionMatch = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)$/);
330
+ if (sessionMatch) {
331
+ const id = decodeURIComponent(sessionMatch[1]);
332
+ if (method === "GET") {
333
+ await handleGetChatSession(req, res, deps, id);
334
+ return true;
335
+ }
336
+ if (method === "DELETE") {
337
+ await handleDeleteChatSession(req, res, deps, id);
338
+ return true;
339
+ }
340
+ }
341
+ if (method === "POST" && pathname === "/api/operator/v1/chat/sessions") {
342
+ await handleCreateChatSession(req, res, deps);
343
+ return true;
344
+ }
345
+ if (isMutationMethod(method)) {
346
+ // Already gated above for known routes; unknown mutation routes 404.
347
+ sendJson(res, 404, {
348
+ ok: false,
349
+ error: { code: "NOT_FOUND", message: `unknown chat route: ${pathname}` },
350
+ });
351
+ return true;
352
+ }
353
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `unknown chat route: ${pathname}` } });
354
+ return true;
355
+ }
356
+ // Re-export for route wiring; writeSseEvent kept for parity with operation-sse.
357
+ void writeSseEvent;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Operator Chat — session store + tool dispatcher (plan W7 / W5).
3
+ *
4
+ * Owns:
5
+ * - Chat session metadata persistence (app-data/chats)
6
+ * - Tool-call dispatch from the Pi runtime into the operator action layer
7
+ * (Gate 3 per-call authorization)
8
+ * - History record (for degraded-mode replay, V13)
9
+ *
10
+ * The dispatcher is the SINGLE place where a Chat tool invocation becomes an
11
+ * operator action call. Every invocation is re-authorized here.
12
+ */
13
+ import path from "node:path";
14
+ import { readJsonIfExists, writeSecureJson, } from "../app-data.js";
15
+ import { dispatchOperatorAction } from "../operator-actions.js";
16
+ import { authorizeOperatorChatTool } from "./tools.js";
17
+ function recordPath(appData, sessionId) {
18
+ return path.join(appData.chats, `${sessionId}.json`);
19
+ }
20
+ export class ChatSessionStore {
21
+ appData;
22
+ /**
23
+ * Per-session write lock. Each mutating op chains onto the previous one so
24
+ * concurrent appendMessage / recordToolInvocation calls do not lose updates
25
+ * via last-write-wins on the same JSON record.
26
+ */
27
+ writeChains = new Map();
28
+ constructor(appData) {
29
+ this.appData = appData;
30
+ }
31
+ async get(sessionId) {
32
+ return readJsonIfExists(recordPath(this.appData, sessionId));
33
+ }
34
+ /** Serialize a mutating op per session. */
35
+ serialize(sessionId, op) {
36
+ const prev = this.writeChains.get(sessionId) ?? Promise.resolve();
37
+ const next = prev.then(op, op);
38
+ // Keep the chain reference for the next serializer; swallow this op's
39
+ // rejection so it never breaks the chain (callers still see the result).
40
+ this.writeChains.set(sessionId, next.then(() => undefined, () => undefined));
41
+ return next;
42
+ }
43
+ async create(input) {
44
+ return this.serialize(input.sessionId, async () => {
45
+ const now = new Date().toISOString();
46
+ const record = {
47
+ schemaVersion: 1,
48
+ sessionId: input.sessionId,
49
+ createdAt: now,
50
+ updatedAt: now,
51
+ modelProvider: input.modelProvider,
52
+ modelId: input.modelId,
53
+ messages: [],
54
+ toolInvocations: [],
55
+ };
56
+ await writeSecureJson(recordPath(this.appData, input.sessionId), record);
57
+ return record;
58
+ });
59
+ }
60
+ async appendMessage(sessionId, message) {
61
+ return this.serialize(sessionId, async () => {
62
+ const record = await this.get(sessionId);
63
+ if (!record)
64
+ throw new Error(`chat session not found: ${sessionId}`);
65
+ const stored = {
66
+ ...message,
67
+ id: `m_${record.messages.length + 1}_${Math.random().toString(36).slice(2, 8)}`,
68
+ at: new Date().toISOString(),
69
+ };
70
+ record.messages.push(stored);
71
+ record.updatedAt = stored.at;
72
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
73
+ return stored;
74
+ });
75
+ }
76
+ async recordToolInvocation(sessionId, entry) {
77
+ await this.serialize(sessionId, async () => {
78
+ const record = await this.get(sessionId);
79
+ if (!record)
80
+ return;
81
+ record.toolInvocations.push({ ...entry, at: new Date().toISOString() });
82
+ record.updatedAt = new Date().toISOString();
83
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
84
+ });
85
+ }
86
+ }
87
+ /**
88
+ * Dispatch a Chat tool invocation into the operator action layer.
89
+ *
90
+ * Gate 3 (per-call): authorizeOperatorChatTool is re-checked here. Even if a
91
+ * malicious or drifted session somehow requested a denied tool, this gate
92
+ * refuses before any operator action runs (V12 / V15).
93
+ *
94
+ * High-risk actions (contractApply / runDag / etc.) are NOT registered as
95
+ * tools, so they never reach here; but we still deny defensively.
96
+ */
97
+ export async function dispatchChatToolCall(ctx, toolName, args, clientRequestId) {
98
+ const decision = authorizeOperatorChatTool(toolName);
99
+ if (!decision.ok) {
100
+ return {
101
+ ok: false,
102
+ toolName,
103
+ errorCode: decision.code,
104
+ message: decision.message,
105
+ };
106
+ }
107
+ const result = await dispatchOperatorAction(ctx, {
108
+ action: decision.toolId,
109
+ actionParams: args,
110
+ clientRequestId,
111
+ });
112
+ if (result.kind === "accepted") {
113
+ // Long-running is not in the whitelist, but accepted ops (none currently)
114
+ // surface a pointer rather than inline data.
115
+ return {
116
+ ok: true,
117
+ toolName: decision.toolId,
118
+ result: {
119
+ operationId: result.body.operationId,
120
+ state: result.body.state,
121
+ note: "operation accepted; poll /api/operator/v1/operations/:id",
122
+ },
123
+ };
124
+ }
125
+ const body = result.body;
126
+ const ok = body.ok === true;
127
+ if (ok) {
128
+ return {
129
+ ok: true,
130
+ toolName: decision.toolId,
131
+ result: body.result ?? body,
132
+ };
133
+ }
134
+ const error = body.error ?? {};
135
+ return {
136
+ ok: false,
137
+ toolName: decision.toolId,
138
+ errorCode: typeof error.code === "string" ? error.code : "INTERNAL_ERROR",
139
+ message: typeof error.message === "string"
140
+ ? error.message
141
+ : `operator action ${decision.toolId} failed`,
142
+ };
143
+ }
144
+ /**
145
+ * Drive one full Chat turn: persist the user message, run the Pi prompt, and
146
+ * surface events to the caller (SSE). Tool-call events are dispatched through
147
+ * dispatchChatToolCall (Gate 3). The assistant's final message is persisted.
148
+ *
149
+ * The dispatcher wiring (connecting the Pi session's tool registry to
150
+ * dispatchChatToolCall) is performed by the runtime's customTools binding,
151
+ * configured at session-create time by chat-session.ts. This helper is the
152
+ * orchestrator the HTTP route calls.
153
+ */
154
+ export async function runChatTurn(options) {
155
+ const { runtime, store, sessionId, text } = options;
156
+ if (!runtime.hasSession(sessionId)) {
157
+ return {
158
+ ok: false,
159
+ error: {
160
+ code: "NOT_FOUND",
161
+ message: `chat session not active: ${sessionId}`,
162
+ },
163
+ };
164
+ }
165
+ await store.appendMessage(sessionId, { role: "user", text });
166
+ let turnError;
167
+ // Collect persistence promises so runChatTurn does not resolve before the
168
+ // chat history / audit trail is durably written (also makes the outcome
169
+ // observable to tests and to a reconnecting client reading the record).
170
+ const pending = [];
171
+ try {
172
+ await runtime.prompt(sessionId, text, (event) => {
173
+ options.onEvent?.(event);
174
+ switch (event.type) {
175
+ case "message_update":
176
+ case "message_end":
177
+ if (event.type === "message_end") {
178
+ pending.push(store.appendMessage(sessionId, {
179
+ role: "assistant",
180
+ text: event.text,
181
+ }));
182
+ }
183
+ break;
184
+ case "tool_call":
185
+ // Forwarded to the caller (SSE) for UI feedback. The actual tool
186
+ // execution happens in the SDK's customTools[].execute(), which
187
+ // routes to dispatchChatToolCall (Gate 3). Do NOT dispatch here —
188
+ // that would double-execute every tool call (P0.2).
189
+ break;
190
+ case "tool_result": {
191
+ // tool_execution_end carries the real result of the tool call.
192
+ // Persist exactly once (the tool message + audit trail). This is
193
+ // the single point of record for tool outcomes (P0.2).
194
+ const resultText = safeStringifyResult(event.result);
195
+ // ChatSessionStore serializes writes per session, so these two calls
196
+ // are safe to fire concurrently — neither clobbers the other.
197
+ pending.push(store.appendMessage(sessionId, {
198
+ role: "tool",
199
+ text: resultText,
200
+ toolCallId: event.toolCallId,
201
+ toolName: event.toolName,
202
+ isError: event.isError,
203
+ }));
204
+ pending.push(store.recordToolInvocation(sessionId, {
205
+ toolCallId: event.toolCallId,
206
+ toolName: event.toolName,
207
+ ok: !event.isError,
208
+ }));
209
+ break;
210
+ }
211
+ case "error":
212
+ turnError = { code: "CHAT_TURN_ERROR", message: event.message };
213
+ break;
214
+ }
215
+ }, { signal: options.signal });
216
+ }
217
+ catch (error) {
218
+ await Promise.allSettled(pending);
219
+ const message = error instanceof Error ? error.message : String(error);
220
+ return { ok: false, error: { code: "CHAT_TURN_FAILED", message } };
221
+ }
222
+ await Promise.allSettled(pending);
223
+ return { ok: !turnError, error: turnError };
224
+ }
225
+ /**
226
+ * Stringify a tool result for persistence. Bounds the stored text length so a
227
+ * huge operator-action payload (e.g. a full dagReport) cannot bloat the chat
228
+ * history record.
229
+ */
230
+ function safeStringifyResult(result) {
231
+ try {
232
+ const str = JSON.stringify(result ?? {});
233
+ return str.length > 4000 ? `${str.slice(0, 4000)}…[truncated]` : str;
234
+ }
235
+ catch {
236
+ return String(result);
237
+ }
238
+ }