loopengine 0.0.17 → 0.0.20

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 (40) hide show
  1. package/README.md +67 -6
  2. package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
  3. package/dist/{cli.d.ts → bin/cli.d.ts} +27 -6
  4. package/dist/bin/cli.js +384 -0
  5. package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
  6. package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
  7. package/dist/core/client.d.ts +113 -0
  8. package/dist/core/client.js +123 -0
  9. package/dist/core/discover-agents.d.ts +45 -0
  10. package/dist/{discover-agents.js → core/discover-agents.js} +12 -4
  11. package/dist/core/gateway-tools.d.ts +226 -0
  12. package/dist/core/gateway-tools.js +660 -0
  13. package/dist/core/loop-events.d.ts +168 -0
  14. package/dist/core/loop-events.js +1 -0
  15. package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
  16. package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
  17. package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
  18. package/dist/core/run-agent.d.ts +182 -0
  19. package/dist/{run-agent.js → core/run-agent.js} +233 -51
  20. package/dist/{session-store.d.ts → core/session-store.d.ts} +13 -1
  21. package/dist/{session-store.js → core/session-store.js} +19 -0
  22. package/dist/core/system-tools/ask_user.d.ts +72 -0
  23. package/dist/core/system-tools/ask_user.js +136 -0
  24. package/dist/core/system-tools/index.d.ts +3 -0
  25. package/dist/core/system-tools/index.js +12 -0
  26. package/dist/core/system-tools/read_file.d.ts +28 -0
  27. package/dist/core/system-tools/read_file.js +64 -0
  28. package/dist/index.d.ts +19 -16
  29. package/dist/index.js +16 -9
  30. package/package.json +33 -25
  31. package/dist/cli.js +0 -191
  32. package/dist/discover-agents.d.ts +0 -24
  33. package/dist/run-agent.d.ts +0 -92
  34. /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
  35. /package/dist/{agent-config.js → core/agent-config.js} +0 -0
  36. /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
  37. /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
  38. /package/dist/{model-calls → core/model-calls}/openai-model-call.js +0 -0
  39. /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
  40. /package/dist/{vector-index.js → core/vector-index.js} +0 -0
@@ -14,18 +14,40 @@ import { ToolLane } from 'toollane';
14
14
  import { Reflow } from 'reflowkit';
15
15
  import { loadAgentModule } from './discover-agents.js';
16
16
  import { agentAsTool } from './agent-as-tool.js';
17
+ import { loadGatewayToolsFromDir } from './gateway-tools.js';
18
+ import { systemTools, createAskUserTool } from './system-tools/index.js';
19
+ export { systemTools } from './system-tools/index.js';
17
20
  // Resolved relative to *this file's own location* (via import.meta.url),
18
- // not process.cwd() — the same reasoning agent-registry.ts's own
19
- // agentsDir uses. In dev (tsx), this file and agents/ are both source
20
- // .ts, side by side. In the built dist/, this file is dist/run-agent.js
21
- // and its sibling agents/ is dist/agents/**/*.js (tsc-compiled) — cwd is
22
- // irrelevant to either case, only "next to this module" is reliably
23
- // right in both. skillsDirs/rules don't need this: actauth.yml/SKILL.md
24
- // are plain data copied verbatim into the image (see Dockerfile), so
25
- // they exist at the same cwd-relative path either way; a tools/index.ts
26
- // is real code that only exists compiled, at a different relative
27
- // location, once dist/ is what's actually running.
28
- const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), 'agents');
21
+ // not process.cwd() — the same reasoning core/agent-registry.ts's own
22
+ // agentsDir uses. In dev (tsx), this file lives in core/ with agents/ one
23
+ // level up, at repo root. In the built dist/, this file is
24
+ // dist/core/run-agent.js with dist/agents/**/*.js (tsc-compiled,
25
+ // preserving the source tree's shape) one level up from *it* the same
26
+ // way — cwd is irrelevant to either case, only "one level up from this
27
+ // module" is reliably right in both. skillsDirs/rules don't need this:
28
+ // actauth.yml/SKILL.md are plain data copied verbatim into the image (see
29
+ // Dockerfile), so they exist at the same cwd-relative path either way; a
30
+ // tools/index.ts is real code that only exists compiled, at a different
31
+ // relative location, once dist/ is what's actually running.
32
+ const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
33
+ // Same "resolved relative to this file's own location" reasoning as
34
+ // agentsRootDir above, but system-skills/ is a same-level sibling of
35
+ // run-agent.ts itself (both live directly under core/ — see package.json's
36
+ // own "files" list), not one level up the way agents/ is, so this one
37
+ // stays a direct join with no '..'.
38
+ export const systemSkillsDir = join(dirname(fileURLToPath(import.meta.url)), 'system-skills');
39
+ // Tools every agent gets (see systemTools' own doc comment) are merged
40
+ // in *first*, not last — combined with keeping each name's *last*
41
+ // occurrence (a plain Map naturally does this: re-setting an existing
42
+ // key overwrites its value without moving it), that means an agent's
43
+ // own tool of the same name (config.tools, a subagent, a gateway source)
44
+ // always wins over the system default, never the reverse. Also used to
45
+ // dedupe the *array itself*, not just resolution at call time — without
46
+ // this, a name collision would still show up twice in toolSchemas,
47
+ // confusing (or rejected outright by) a real model API.
48
+ export function dedupeToolsByName(list) {
49
+ return [...new Map(list.map((t) => [t.name, t])).values()];
50
+ }
29
51
  /** ContextClip only ever needs a flat string per message to estimate
30
52
  * token usage — it doesn't need to understand tool_use/tool_result
31
53
  * structure, so this is a one-way, read-only projection, never something
@@ -139,7 +161,7 @@ export async function loadDefaultTools(config) {
139
161
  * `agents/<name>/` — correct for a top-level agent, where that's exactly
140
162
  * where its module lives, but wrong for a subagent: its `config` only
141
163
  * carries its own bare name (e.g. 'billing-agent'), not the nested folder
142
- * loadSubagentTools actually found it in
164
+ * loadSubagentAsTools actually found it in
143
165
  * (`agents/support-orchestrator/subagents/billing-agent/`). Left alone,
144
166
  * an omitted `tools`/`rules`/`skillsDirs` on a subagent would silently
145
167
  * resolve against `agents/billing-agent/...` instead — empty, if no such
@@ -154,12 +176,19 @@ export async function loadDefaultTools(config) {
154
176
  * deny-everything ruleset" fallback loadRules's own default path gets —
155
177
  * computed here instead of left to loadRules, since leaving `rules`
156
178
  * unset would have it fall through to loadRules's *own* (wrong,
157
- * name-based) default path instead of this one. */
179
+ * name-based) default path instead of this one.
180
+ *
181
+ * `gateway-tools.yml` (see gateway-tools.ts) gets the same dir-correctness
182
+ * fix, but merged in unconditionally rather than gated on `tools` being
183
+ * unset — same reasoning loadSubagentAsTools' own unconditional merge into
184
+ * the top-level tools line has: gateway-registered tools are an
185
+ * operator/admin concern, distinct from hand-written `tools`, so setting
186
+ * `tools` explicitly shouldn't opt a subagent out of its own
187
+ * gateway-tools.yml the way it opts out of the tools/ folder default. */
158
188
  async function resolveSubagentConfig(config, dir) {
159
189
  const resolved = { ...config };
160
- if (resolved.tools === undefined) {
161
- resolved.tools = await loadToolsFromDir(join(dir, 'tools'));
162
- }
190
+ const baseTools = resolved.tools === undefined ? await loadToolsFromDir(join(dir, 'tools')) : resolved.tools;
191
+ resolved.tools = [...baseTools, ...(await loadGatewayToolsFromDir(dir))];
163
192
  if (resolved.rules === undefined) {
164
193
  const rulesPath = join(dir, 'actauth.yml');
165
194
  if (existsSync(rulesPath)) {
@@ -196,7 +225,7 @@ async function resolveSubagentConfig(config, dir) {
196
225
  * called again, one level down. A folder can't be its own ancestor, so
197
226
  * this can't cycle the way a hand-wired agentAsTool(getEntry(...)) call
198
227
  * elsewhere could. */
199
- async function loadSubagentTools(config) {
228
+ export async function loadSubagentAsTools(config) {
200
229
  const subagentsDir = join(agentsRootDir, config.name, 'subagents');
201
230
  if (!existsSync(subagentsDir))
202
231
  return [];
@@ -229,27 +258,46 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
229
258
  // (including `[]`) is used as-is; omitted entirely defaults to
230
259
  // importing agents/<name>/tools/index.{ts,js} — see loadDefaultTools's
231
260
  // own doc comment for the full reasoning and the cases that can't use it.
232
- // agents/<name>/subagents/* is merged in on top either way — see
233
- // loadSubagentTools's own doc comment for why that one isn't gated by
261
+ // agents/<name>/subagents/* and agents/<name>/gateway-tools.yml (see
262
+ // gateway-tools.ts) are both merged in on top either way — see
263
+ // loadSubagentAsTools's own doc comment for why neither is gated by
234
264
  // whether `tools` was explicit.
235
- const tools = [...(config.tools ?? (await loadDefaultTools(config))), ...(await loadSubagentTools(config))];
265
+ // systemTools go first and are deduped by name (dedupeToolsByName keeps
266
+ // each name's *last* occurrence) — so config.tools/subagents/gateway
267
+ // tools always win over a same-named system default, never the
268
+ // reverse, even though systemTools is merged in unconditionally
269
+ // (unlike the others below, it's not gated on `tools` being omitted).
270
+ // ask_user is built fresh per call, not part of the static systemTools
271
+ // array — its onPending needs options.onQuestionPending, not `log`
272
+ // (options.onEvent's own fallback) — see RunAgentOptions.onQuestionPending's
273
+ // own doc comment for why those two are deliberately not the same thing.
274
+ const askUserTool = createAskUserTool({ agent: config.name, sessionId: options.sessionId }, options.onQuestionPending);
275
+ const tools = dedupeToolsByName([
276
+ ...systemTools,
277
+ askUserTool,
278
+ ...(config.tools ?? (await loadDefaultTools(config))),
279
+ ...(await loadSubagentAsTools(config)),
280
+ ...(await loadGatewayToolsFromDir(join(agentsRootDir, config.name))),
281
+ ]);
236
282
  // Omitted entirely (undefined): default to this agent's own
237
283
  // agents/<name>/skills — the folder-form convention every agent in
238
284
  // this repo already follows (see agent-config.ts's skillsDirs doc
239
- // comment). An explicit `[]` is a real opt-out, not "unset," and skips
240
- // the default — SkillGarden's own missing-directory handling
241
- // (discoverSkillFiles walks best-effort, no throw) is what makes
242
- // pointing this at a folder that doesn't exist (a flat-file agent with
243
- // no skills at all) harmless: an empty index, not an error.
285
+ // comment). An explicit `[]` is a real opt-out of the agent's *own*
286
+ // skills dir, but systemSkillsDir is still always included below —
287
+ // same "not opt-out-able the normal way" as systemTools above.
288
+ // SkillGarden's own missing-directory handling (discoverSkillFiles
289
+ // walks best-effort, no throw) is what makes pointing this at a folder
290
+ // that doesn't exist (a flat-file agent with no skills at all)
291
+ // harmless: an empty index, not an error.
244
292
  const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
245
- const skillGarden = skillsDirs.length ? new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 }) : null;
246
- const skillIndex = skillGarden?.buildIndex().included ?? [];
293
+ const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 });
294
+ const skillIndex = skillGarden.buildIndex().included;
247
295
  // Also the tail-preservation window recover() below relies on — kept as
248
296
  // one named constant so the two can never drift out of sync.
249
297
  const tailMessages = 4;
250
298
  const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages });
251
299
  const rules = loadRules(config);
252
- const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
300
+ const gate = new Gate(rules, config.approver ?? options.approver ?? new ConsoleApprover());
253
301
  // No explicit isSafeTool: fall back to each called tool's own `safe`
254
302
  // flag (looked up by name) rather than defaulting every tool to unsafe
255
303
  // outright — see ToolDefinition.safe's own doc comment for why this is
@@ -281,7 +329,7 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
281
329
  const priorCount = currentMessages.length - newMessages.length;
282
330
  const priorPortion = currentMessages.slice(0, priorCount);
283
331
  const result = await contextClip.recover(priorPortion.map(flattenForContextClip));
284
- log('reflow:recover', { from: currentMessages.length, to: result.messages.length + newMessages.length });
332
+ log({ type: 'reflow:recover', from: currentMessages.length, to: result.messages.length + newMessages.length });
285
333
  if (result.action === 'unchanged')
286
334
  return currentMessages;
287
335
  // Same tail-preservation reuse as before, just scoped to
@@ -307,6 +355,19 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
307
355
  },
308
356
  });
309
357
  const toolsByName = new Map(tools.map((t) => [t.name, t]));
358
+ // Object identity, not name — an agent that overrides a system tool's
359
+ // name with its own ToolDefinition (dedupeToolsByName's own "config
360
+ // wins" rule above) is a deliberate opt-out, and that override should
361
+ // still go through the agent's own rules like anything else; only the
362
+ // genuine, unmodified system implementation always bypasses the gate.
363
+ // ask_user in particular can't be gated at all without a real deadlock:
364
+ // it's the mechanism a human uses to answer the agent, so requiring a
365
+ // human decision just to *ask* one is circular, not just redundant —
366
+ // and every default_decision this repo's own agents actually use
367
+ // ('ask' or 'deny', see loadRules' own doc comment) would otherwise
368
+ // catch it, since neither system tool ever appears in an agent's own
369
+ // actauth.yml.
370
+ const systemToolInstances = new Set([...systemTools, askUserTool]);
310
371
  const toolSchemas = tools.map(({ name, description, input_schema }) => ({
311
372
  name,
312
373
  description,
@@ -349,16 +410,16 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
349
410
  if (turn > maxTurns) {
350
411
  const text = `Stopped after ${maxTurns} turns without a final answer — the agent may be stuck in a loop.`;
351
412
  pushMessage({ role: 'assistant', content: text });
352
- log('loop:max_turns', { maxTurns });
413
+ log({ type: 'loop:max_turns', maxTurns });
353
414
  return { text, history: messages, newMessages, stopReason: 'max_turns' };
354
415
  }
355
416
  const budget = contextClip.check(messages.map(flattenForContextClip));
356
- log('contextclip:check', budget);
417
+ log({ type: 'contextclip:check', ...budget });
357
418
  if (budget.action === 'nudge' && budget.nudge)
358
419
  pushMessage(budget.nudge);
359
420
  const { value: response, recoveries } = await reflow.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
360
421
  if (recoveries.length > 0)
361
- log('reflow:recoveries', recoveries);
422
+ log({ type: 'reflow:recoveries', recoveries });
362
423
  // The model's full response — text and tool_use blocks alike, with
363
424
  // real ids — becomes this turn's assistant message verbatim. Every
364
425
  // tool_use block emitted here gets a matching tool_result pushed
@@ -368,11 +429,34 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
368
429
  const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
369
430
  if (toolUseBlocks.length === 0) {
370
431
  const text = response.content.find((b) => b.type === 'text')?.text ?? '';
371
- log('loop:done', text);
432
+ log({ type: 'loop:done', text });
372
433
  return { text, history: messages, newMessages };
373
434
  }
435
+ // The model's own text alongside a tool_use request (its "I'll do X"
436
+ // preamble) is already durable via pushMessage above, but the only
437
+ // *live* SSE event that ever carries assistant text is 'done', which
438
+ // fires once at the very end of the whole turn — a live caller
439
+ // watching this turn in progress (adapters/http.ts's streaming route,
440
+ // the playground) would otherwise jump straight from "thinking..." to
441
+ // an approval/question card with zero explanation, and only ever see
442
+ // this sentence later, after a refresh replays stored history —
443
+ // confirmed live.
444
+ const preambleText = response.content.find((b) => b.type === 'text')?.text;
445
+ if (preambleText)
446
+ log({ type: 'assistant:text', text: preambleText });
374
447
  const resultBlocks = [];
375
448
  const approvedCalls = [];
449
+ const deniedTools = [];
450
+ // Keyed by tool_use id — approvedCalls (a toollane LaneCall) has no
451
+ // room for extra fields of its own, and toolLane.run's own results
452
+ // only carry `id`/`name`/status, not anything about the *decision*
453
+ // that let a call through in the first place (or what it was even
454
+ // called with). This is what lets the reason still get attached to
455
+ // the right tool_result once execution finishes below, and lets the
456
+ // 'tool:result' live event (see below) report the same args a
457
+ // resumed/refreshed view of this same call would show, without
458
+ // changing toollane's own types.
459
+ const approvedMetaById = new Map();
376
460
  for (const block of toolUseBlocks) {
377
461
  // Skill invocation injects instructions into context; it isn't a
378
462
  // real tool call and doesn't need ActAuth gating or ToolLane
@@ -380,13 +464,43 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
380
464
  // so it still needs a tool_result to answer it, same as any other.
381
465
  if (block.name === 'Skill' && skillGarden) {
382
466
  const body = skillGarden.invoke(block.input.skill, block.input?.args);
383
- log('skillgarden:invoke', block.input.skill);
467
+ log({ type: 'skillgarden:invoke', skill: block.input.skill });
384
468
  resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
385
469
  continue;
386
470
  }
471
+ // See systemToolInstances' own doc comment above — bypasses
472
+ // gate.evaluate entirely, not just auto-approves through it, since
473
+ // ask_user itself can be the approver's own only way to ask a human
474
+ // anything in the first place.
475
+ if (systemToolInstances.has(toolsByName.get(block.name))) {
476
+ log({ type: 'actauth:decision', tool: block.name, decision: 'allow', reason: 'system tool — always allowed' });
477
+ approvedMetaById.set(block.id, { reason: 'system tool — always allowed', args: block.input ?? {} });
478
+ // Fired the moment the decision is in, not once execution finishes
479
+ // (see the 'tool:started' vs 'tool:result' split below) — a system
480
+ // tool can still be slow, and this is the one path that had no
481
+ // interactive card of its own to show something sooner.
482
+ log({ type: 'tool:started', id: block.id, tool: block.name, args: block.input ?? {}, detailText: 'system tool — always allowed' });
483
+ approvedCalls.push({
484
+ id: block.id,
485
+ name: block.name,
486
+ execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
487
+ });
488
+ continue;
489
+ }
387
490
  const decision = await gate.evaluate(block.name, block.input ?? {}, scope);
388
- log('actauth:decision', { tool: block.name, decision: decision.decision, reason: decision.reason });
491
+ log({ type: 'actauth:decision', tool: block.name, decision: decision.decision, reason: decision.reason });
389
492
  if (decision.decision === 'allow') {
493
+ approvedMetaById.set(block.id, { reason: decision.reason, args: block.input ?? {} });
494
+ // Fired the instant the decision is in, not once execution
495
+ // finishes below — closes what would otherwise be a multi-second
496
+ // silent gap while a slow call runs. Always emitted, whether or
497
+ // not a human was actually asked live: run-agent.ts itself has no
498
+ // notion of "already shown elsewhere" — that's a rendering
499
+ // decision, and belongs to whichever channel adapter is actually
500
+ // presenting this turn (see web/playground.ts's own
501
+ // wasAskedInteractively, which decides whether this duplicates an
502
+ // approval card already on screen), not to the engine loop.
503
+ log({ type: 'tool:started', id: block.id, tool: block.name, args: block.input ?? {}, detailText: decision.reason });
390
504
  approvedCalls.push({
391
505
  id: block.id,
392
506
  name: block.name,
@@ -397,31 +511,99 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
397
511
  // Every requested tool gets exactly one tool_result back — a
398
512
  // denied call is not simply dropped, or the model has no way to
399
513
  // tell "denied" apart from "hasn't run yet" and may just
400
- // re-request it forever.
514
+ // re-request it forever. Recorded even though this turn is about
515
+ // to stop (see deniedTools below) — a later message continuing
516
+ // this same session still needs a complete, consistent history,
517
+ // not a dangling tool_use with no answer.
518
+ deniedTools.push(block.name);
401
519
  resultBlocks.push({
402
520
  type: 'tool_result',
403
521
  tool_use_id: block.id,
404
522
  content: `denied: ${decision.reason}`,
405
523
  is_error: true,
406
524
  });
525
+ // Always emitted — see the 'allow' branch's own comment above on
526
+ // why this engine loop no longer decides whether a live UI
527
+ // already has some other representation of this decision on
528
+ // screen (an approval card that went through an interactive
529
+ // ask). That's a rendering call, made by whichever channel
530
+ // adapter is presenting this turn.
531
+ log({ type: 'tool:result', id: block.id, tool: block.name, args: block.input ?? {}, detailText: decision.reason, statusText: 'Denied.' });
532
+ }
533
+ }
534
+ if (deniedTools.length > 0) {
535
+ // A denial cancels the *whole* batch, not just the call(s) that
536
+ // were themselves denied — an approved sibling call from the same
537
+ // turn never runs at all, same reasoning Claude Code itself stops
538
+ // an entire pending batch rather than quietly carrying out the
539
+ // parts you didn't object to. Every approved call still gets
540
+ // exactly one tool_result back regardless (the model emitted a
541
+ // tool_use for it, so it needs an answer) — "skipped", not
542
+ // "denied": it was never evaluated against its own rule, a
543
+ // *different* call in the same turn was what stopped it.
544
+ for (const call of approvedCalls) {
545
+ log({ type: 'loop:skipped', name: call.name, deniedTools });
546
+ const skipReason = `a sibling tool call in this turn (${deniedTools.join(', ')}) was denied`;
547
+ resultBlocks.push({
548
+ type: 'tool_result',
549
+ tool_use_id: call.id,
550
+ content: `skipped: ${skipReason}`,
551
+ is_error: true,
552
+ });
553
+ // A skipped call never went through gate.evaluate at all — no
554
+ // approver, no interactive card, nothing live ever showed it was
555
+ // even requested. Same reasoning as the straight-denied case
556
+ // just above.
557
+ log({ type: 'tool:result', id: call.id, tool: call.name, args: approvedMetaById.get(call.id)?.args, detailText: skipReason, statusText: 'Skipped.' });
407
558
  }
408
559
  }
409
- // result.id is the same id LaneCall.id was given above — the exact
410
- // per-call identity that makes it possible to link a result back to
411
- // the specific tool_use block that requested it, even when several
412
- // calls ran in the same parallel lane. The old flattened-text design
413
- // could only link by tool *name*, ambiguous the moment two calls to
414
- // the same tool ran in one turn.
415
- for await (const result of toolLane.run(approvedCalls)) {
416
- const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
417
- log('toollane:result', { name: result.name, summary });
418
- resultBlocks.push({
419
- type: 'tool_result',
420
- tool_use_id: result.id,
421
- content: summary,
422
- is_error: result.status === 'rejected',
423
- });
560
+ else {
561
+ // result.id is the same id LaneCall.id was given above — the exact
562
+ // per-call identity that makes it possible to link a result back
563
+ // to the specific tool_use block that requested it, even when
564
+ // several calls ran in the same parallel lane. The old
565
+ // flattened-text design could only link by tool *name*, ambiguous
566
+ // the moment two calls to the same tool ran in one turn.
567
+ for await (const result of toolLane.run(approvedCalls)) {
568
+ const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
569
+ const meta = approvedMetaById.get(result.id);
570
+ log({ type: 'toollane:result', name: result.name, summary });
571
+ resultBlocks.push({
572
+ type: 'tool_result',
573
+ tool_use_id: result.id,
574
+ content: summary,
575
+ is_error: result.status === 'rejected',
576
+ reason: meta?.reason,
577
+ });
578
+ // Always emitted — this is what turns the earlier 'tool:started'
579
+ // event's "Running…" placeholder into the real outcome (matched
580
+ // by the same id), for every call regardless of how it got
581
+ // approved. See the 'allow' branch's own comment above: deciding
582
+ // whether this duplicates an approval card already on screen is
583
+ // a rendering call for the channel adapter presenting this turn,
584
+ // not something this engine loop tracks.
585
+ log({
586
+ type: 'tool:result',
587
+ id: result.id,
588
+ tool: result.name,
589
+ args: meta?.args,
590
+ detailText: meta?.reason,
591
+ statusText: result.status === 'fulfilled' ? 'Approved.' : 'Error.',
592
+ });
593
+ }
424
594
  }
425
595
  pushMessage({ role: 'user', content: resultBlocks });
596
+ // Stop here, don't loop back for another model call — same reasoning
597
+ // Claude Code itself stops and waits for you rather than working
598
+ // around a tool call you just rejected, instead of silently handing
599
+ // "denied: ..." to the model and letting it decide on its own what to
600
+ // try next (retry the same tool, reach for a different one, or just
601
+ // talk its way past the refusal).
602
+ if (deniedTools.length > 0) {
603
+ const text = `Stopped — you denied: ${deniedTools.join(', ')}. Send another message to continue.`;
604
+ pushMessage({ role: 'assistant', content: text });
605
+ log({ type: 'loop:denied', deniedTools });
606
+ return { text, history: messages, newMessages, stopReason: 'denied' };
607
+ }
426
608
  }
427
609
  }
@@ -1,6 +1,6 @@
1
1
  import { Redis } from 'ioredis';
2
2
  import { type Storage } from 'sessionknit';
3
- import type { Message } from '#run-agent.js';
3
+ import type { Message } from '#core/run-agent.js';
4
4
  export interface SessionResult<T> {
5
5
  /** Exactly what this turn added — durably appended as-is, in order.
6
6
  * This has to come from the caller explicitly (runAgent's own
@@ -18,6 +18,17 @@ export interface SessionStore {
18
18
  * process or (for RedisSessionStore) any other — durably appends
19
19
  * fn's result.newMessages in order, and resolves with fn's result. */
20
20
  withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
21
+ /** Read-only peek at a session's current history — no new turn, nothing
22
+ * appended. Backs adapters/http.ts's GET .../sessions/:id (the
23
+ * playground's "resume a past conversation" sidebar rehydrates the chat
24
+ * pane from this, rather than pretending a new message was sent).
25
+ * Deliberately *not* behind withSession's own per-sessionId exclusivity
26
+ * — see the concrete implementation's own doc comment for why that's
27
+ * safe, and why this needs to work *especially* while a turn is still
28
+ * in flight (blocked on a human — see web/web-approver.ts /
29
+ * system-tools/ask_user.ts),
30
+ * not only once it's finished. */
31
+ getHistory(sessionId: string): Promise<Message[]>;
21
32
  close(): Promise<void>;
22
33
  }
23
34
  /** Serializes async work per key within this process. Good enough for
@@ -40,6 +51,7 @@ declare class SessionKnitStore implements SessionStore {
40
51
  private readonly knit;
41
52
  constructor(storage: Storage<Message>, lock: <T>(sessionId: string, fn: () => Promise<T>) => Promise<T>, onClose?: () => Promise<void>);
42
53
  withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
54
+ getHistory(sessionId: string): Promise<Message[]>;
43
55
  close(): Promise<void>;
44
56
  }
45
57
  /** One JSONL entry log per session under `dir`. Fine for a single local
@@ -107,6 +107,25 @@ class SessionKnitStore {
107
107
  return result;
108
108
  });
109
109
  }
110
+ // Deliberately *not* behind this.lock the way withSession is — that
111
+ // lock is held for withSession's *entire* fn(), which can now mean an
112
+ // 'ask' decision or an ask_user question blocked on a human for
113
+ // minutes (see web/web-approver.ts / system-tools/ask_user.ts). Confirmed
114
+ // live: a session with a pending question hung this call indefinitely,
115
+ // which is exactly backwards for what it's for — the playground's own
116
+ // "resume a session" flow (see its own checkForPendingItems) needs this
117
+ // to work *especially* while something's pending, not only once it
118
+ // isn't. Safe unlocked: withSession's own append loop only ever runs
119
+ // after fn() has already fully resolved (see its own body — nothing
120
+ // about the *next* turn is written until then), so there's nothing for
121
+ // an in-flight turn to race this against except that append loop
122
+ // itself, a few awaits, not the human-wait duration in front of it. A
123
+ // read that lands mid-append just sees an older, still-fully-valid
124
+ // prefix, not torn data.
125
+ async getHistory(sessionId) {
126
+ const { messages } = await this.knit.resume(sessionId);
127
+ return messages;
128
+ }
110
129
  async close() {
111
130
  await this.onClose();
112
131
  }
@@ -0,0 +1,72 @@
1
+ import type { ToolDefinition } from '../agent-config.js';
2
+ export interface PendingQuestion {
3
+ id: string;
4
+ question: string;
5
+ /** Suggested answers, if the model gave any — never the only allowed
6
+ * answer, a human can still type free text either way (see
7
+ * answerQuestion, which takes any string). */
8
+ options?: string[];
9
+ /** Which agent raised this — always known (config.name), so listing can
10
+ * always at least be scoped per-agent even without a session id. */
11
+ agent: string;
12
+ /** Which conversation raised this, if the caller has one (see
13
+ * RunAgentOptions.sessionId's own doc comment for when it doesn't) —
14
+ * lets listQuestions scope down to exactly one conversation instead of
15
+ * every question raised by this agent, or this whole process. */
16
+ sessionId?: string;
17
+ requestedAt: string;
18
+ }
19
+ /** Builds the ask_user ToolDefinition for one runAgent() call.
20
+ *
21
+ * context identifies *whose* question this is (see PendingQuestion's own
22
+ * doc comment) — without it, listQuestions() would have no way to scope
23
+ * down to one agent/conversation, and every caller would see every
24
+ * pending question this whole process has ever raised.
25
+ *
26
+ * onPending, when given, is how a live caller (adapters/http.ts's
27
+ * streaming route passes `(q) => log('question:pending', q)`, which
28
+ * onEvent forwards straight onto that turn's SSE connection — see
29
+ * playground.ts's own inline card for it) finds out immediately instead
30
+ * of having to poll listQuestions(). It's a notification only, not a
31
+ * routing key: the question is registered in the same shared
32
+ * questionsById either way, so answerQuestion() works regardless of
33
+ * whether anything was listening for onPending in the first place.
34
+ *
35
+ * Omitted entirely (a plain CLI run, or any caller with no live channel
36
+ * of its own) falls back to a blocking terminal prompt instead of a
37
+ * pending entry nothing could ever answer — same reasoning
38
+ * ConsoleApprover exists for permission asks with nowhere else to go.
39
+ *
40
+ * Named `system_ask_user`, not the bare `ask_user` an agent author would
41
+ * naturally reach for on their own — see system-tools/read_file.ts's own
42
+ * doc comment on the same `system_` prefix for the full reasoning
43
+ * (unlikely to collide by accident, and run-agent.ts's own
44
+ * systemToolInstances gate bypass treats a same-named override as a
45
+ * deliberate opt-out when one does happen). It matters even more here
46
+ * than for read_file: this tool *is* a human's only way to answer the
47
+ * agent in the first place, so it can never be gated at all without a
48
+ * real deadlock, not just a redundant check — a name any other tool
49
+ * could plausibly reach for by accident would risk silently losing that
50
+ * guarantee, not just risk a surprising override. */
51
+ export declare function createAskUserTool(context: {
52
+ agent: string;
53
+ sessionId?: string;
54
+ }, onPending?: (question: PendingQuestion) => void): ToolDefinition;
55
+ /** Oldest first, same ordering reasoning web/web-approver.ts's listApprovals
56
+ * uses — whichever question has been waiting longest surfaces first.
57
+ * Unfiltered (both omitted) returns every question pending anywhere in
58
+ * this process — real uses (an operator's own admin view, say) should
59
+ * filter, since that's every conversation's questions mixed together. */
60
+ export declare function listQuestions(filter?: {
61
+ agent?: string;
62
+ sessionId?: string;
63
+ }): PendingQuestion[];
64
+ /** Peek at a pending question's own entry (its agent/sessionId, in
65
+ * particular) without answering it — adapters/http.ts needs this *before*
66
+ * calling answerQuestion() below, to know which turn's early-return race
67
+ * (see its own sessionTurns) to resume once this one's answered. */
68
+ export declare function findQuestion(id: string): PendingQuestion | undefined;
69
+ /** Returns false for an unknown/already-answered/timed-out id, so the
70
+ * caller can report that distinctly (a 404) instead of silently
71
+ * no-opping — same convention web/web-approver.ts's decideApproval uses. */
72
+ export declare function answerQuestion(id: string, answer: string): boolean;