loopengine 0.0.18 → 0.0.21

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/PROTOCOL.md +245 -0
  2. package/README.md +44 -5
  3. package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
  4. package/dist/{cli.d.ts → bin/cli.d.ts} +25 -4
  5. package/dist/{cli.js → bin/cli.js} +131 -16
  6. package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
  7. package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
  8. package/dist/core/client.d.ts +113 -0
  9. package/dist/core/client.js +123 -0
  10. package/dist/core/discover-agents.d.ts +45 -0
  11. package/dist/{discover-agents.js → core/discover-agents.js} +11 -3
  12. package/dist/{gateway-tools.d.ts → core/gateway-tools.d.ts} +38 -6
  13. package/dist/{gateway-tools.js → core/gateway-tools.js} +140 -28
  14. package/dist/core/loop-events.d.ts +175 -0
  15. package/dist/core/loop-events.js +1 -0
  16. package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
  17. package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
  18. package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
  19. package/dist/{model-calls → core/model-calls}/openai-model-call.js +1 -1
  20. package/dist/{run-agent.d.ts → core/run-agent.d.ts} +79 -11
  21. package/dist/{run-agent.js → core/run-agent.js} +242 -60
  22. package/dist/{session-store.d.ts → core/session-store.d.ts} +14 -2
  23. package/dist/{session-store.js → core/session-store.js} +19 -0
  24. package/dist/core/system-tools/ask_user.d.ts +72 -0
  25. package/dist/core/system-tools/ask_user.js +136 -0
  26. package/dist/core/system-tools/index.d.ts +3 -0
  27. package/dist/core/system-tools/index.js +12 -0
  28. package/dist/core/system-tools/read_file.d.ts +28 -0
  29. package/dist/core/system-tools/read_file.js +64 -0
  30. package/dist/{vector-index.js → core/vector-index.js} +1 -1
  31. package/dist/index.d.ts +19 -16
  32. package/dist/index.js +16 -9
  33. package/package.json +34 -28
  34. package/protocol/loop-event.schema.json +257 -0
  35. package/dist/discover-agents.d.ts +0 -24
  36. /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
  37. /package/dist/{agent-config.js → core/agent-config.js} +0 -0
  38. /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
  39. /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
  40. /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
@@ -9,29 +9,51 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { parse as parseYaml } from 'yaml';
10
10
  import { Gate, RuleSet, ConsoleApprover } from 'actauth';
11
11
  import { SkillGarden } from 'skillgarden';
12
- import { ContextClipper } from 'contextclip';
12
+ import { BudgetTracker } from './budget.js';
13
+ import { Compactor } from './compaction.js';
13
14
  import { ToolLane } from 'toollane';
14
- import { Reflow } from 'reflowkit';
15
+ import { Recovery } from './recovery.js';
15
16
  import { loadAgentModule } from './discover-agents.js';
16
17
  import { agentAsTool } from './agent-as-tool.js';
17
18
  import { loadGatewayToolsFromDir } from './gateway-tools.js';
19
+ import { systemTools, createAskUserTool } from './system-tools/index.js';
20
+ export { systemTools } from './system-tools/index.js';
18
21
  // Resolved relative to *this file's own location* (via import.meta.url),
19
- // not process.cwd() — the same reasoning agent-registry.ts's own
20
- // agentsDir uses. In dev (tsx), this file and agents/ are both source
21
- // .ts, side by side. In the built dist/, this file is dist/run-agent.js
22
- // and its sibling agents/ is dist/agents/**/*.js (tsc-compiled) — cwd is
23
- // irrelevant to either case, only "next to this module" is reliably
24
- // right in both. skillsDirs/rules don't need this: actauth.yml/SKILL.md
25
- // are plain data copied verbatim into the image (see Dockerfile), so
26
- // they exist at the same cwd-relative path either way; a tools/index.ts
27
- // is real code that only exists compiled, at a different relative
28
- // location, once dist/ is what's actually running.
29
- const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), 'agents');
30
- /** ContextClip only ever needs a flat string per message to estimate
31
- * token usage — it doesn't need to understand tool_use/tool_result
22
+ // not process.cwd() — the same reasoning core/agent-registry.ts's own
23
+ // agentsDir uses. In dev (tsx), this file lives in core/ with agents/ one
24
+ // level up, at repo root. In the built dist/, this file is
25
+ // dist/core/run-agent.js with dist/agents/**/*.js (tsc-compiled,
26
+ // preserving the source tree's shape) one level up from *it* the same
27
+ // way — cwd is irrelevant to either case, only "one level up from this
28
+ // module" is reliably right in both. skillsDirs/rules don't need this:
29
+ // actauth.yml/SKILL.md are plain data copied verbatim into the image (see
30
+ // Dockerfile), so they exist at the same cwd-relative path either way; a
31
+ // tools/index.ts is real code that only exists compiled, at a different
32
+ // relative location, once dist/ is what's actually running.
33
+ const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
34
+ // Same "resolved relative to this file's own location" reasoning as
35
+ // agentsRootDir above, but system-skills/ is a same-level sibling of
36
+ // run-agent.ts itself (both live directly under core/ — see package.json's
37
+ // own "files" list), not one level up the way agents/ is, so this one
38
+ // stays a direct join with no '..'.
39
+ export const systemSkillsDir = join(dirname(fileURLToPath(import.meta.url)), 'system-skills');
40
+ // Tools every agent gets (see systemTools' own doc comment) are merged
41
+ // in *first*, not last — combined with keeping each name's *last*
42
+ // occurrence (a plain Map naturally does this: re-setting an existing
43
+ // key overwrites its value without moving it), that means an agent's
44
+ // own tool of the same name (config.tools, a subagent, a gateway source)
45
+ // always wins over the system default, never the reverse. Also used to
46
+ // dedupe the *array itself*, not just resolution at call time — without
47
+ // this, a name collision would still show up twice in toolSchemas,
48
+ // confusing (or rejected outright by) a real model API.
49
+ export function dedupeToolsByName(list) {
50
+ return [...new Map(list.map((t) => [t.name, t])).values()];
51
+ }
52
+ /** budget.ts/compaction.ts only ever need a flat string per message to
53
+ * estimate token usage — neither needs to understand tool_use/tool_result
32
54
  * structure, so this is a one-way, read-only projection, never something
33
55
  * that needs to be un-projected back. */
34
- function flattenForContextClip(message) {
56
+ function flattenForBudget(message) {
35
57
  if (typeof message.content === 'string')
36
58
  return { role: message.role, content: message.content };
37
59
  const text = message.content
@@ -241,28 +263,49 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
241
263
  // gateway-tools.ts) are both merged in on top either way — see
242
264
  // loadSubagentAsTools's own doc comment for why neither is gated by
243
265
  // whether `tools` was explicit.
244
- const tools = [
266
+ // systemTools go first and are deduped by name (dedupeToolsByName keeps
267
+ // each name's *last* occurrence) — so config.tools/subagents/gateway
268
+ // tools always win over a same-named system default, never the
269
+ // reverse, even though systemTools is merged in unconditionally
270
+ // (unlike the others below, it's not gated on `tools` being omitted).
271
+ // ask_user is built fresh per call, not part of the static systemTools
272
+ // array — its onPending needs options.onQuestionPending, not `log`
273
+ // (options.onEvent's own fallback) — see RunAgentOptions.onQuestionPending's
274
+ // own doc comment for why those two are deliberately not the same thing.
275
+ const askUserTool = createAskUserTool({ agent: config.name, sessionId: options.sessionId }, options.onQuestionPending);
276
+ const tools = dedupeToolsByName([
277
+ ...systemTools,
278
+ askUserTool,
245
279
  ...(config.tools ?? (await loadDefaultTools(config))),
246
280
  ...(await loadSubagentAsTools(config)),
247
281
  ...(await loadGatewayToolsFromDir(join(agentsRootDir, config.name))),
248
- ];
282
+ ]);
249
283
  // Omitted entirely (undefined): default to this agent's own
250
284
  // agents/<name>/skills — the folder-form convention every agent in
251
285
  // this repo already follows (see agent-config.ts's skillsDirs doc
252
- // comment). An explicit `[]` is a real opt-out, not "unset," and skips
253
- // the default — SkillGarden's own missing-directory handling
254
- // (discoverSkillFiles walks best-effort, no throw) is what makes
255
- // pointing this at a folder that doesn't exist (a flat-file agent with
256
- // no skills at all) harmless: an empty index, not an error.
286
+ // comment). An explicit `[]` is a real opt-out of the agent's *own*
287
+ // skills dir, but systemSkillsDir is still always included below —
288
+ // same "not opt-out-able the normal way" as systemTools above.
289
+ // SkillGarden's own missing-directory handling (discoverSkillFiles
290
+ // walks best-effort, no throw) is what makes pointing this at a folder
291
+ // that doesn't exist (a flat-file agent with no skills at all)
292
+ // harmless: an empty index, not an error.
257
293
  const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
258
- const skillGarden = skillsDirs.length ? new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 }) : null;
259
- const skillIndex = skillGarden?.buildIndex().included ?? [];
294
+ const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 });
295
+ const skillIndex = skillGarden.buildIndex().included;
260
296
  // Also the tail-preservation window recover() below relies on — kept as
261
297
  // one named constant so the two can never drift out of sync.
262
298
  const tailMessages = 4;
263
- const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages });
299
+ // Two separate objects, not one — budget.ts's BudgetTracker (read-only
300
+ // check) and compaction.ts's Compactor (the actual recovery) are
301
+ // deliberately distinct capabilities now, constructed with the same
302
+ // budgetTokens/softThreshold so a nudge firing and compaction's own
303
+ // recovery target agree — see budget.ts's own BudgetTrackerOptions doc
304
+ // comment.
305
+ const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 8000 });
306
+ const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 8000, softThreshold: budgetTracker.softThreshold, tailMessages });
264
307
  const rules = loadRules(config);
265
- const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
308
+ const gate = new Gate(rules, config.approver ?? options.approver ?? new ConsoleApprover());
266
309
  // No explicit isSafeTool: fall back to each called tool's own `safe`
267
310
  // flag (looked up by name) rather than defaulting every tool to unsafe
268
311
  // outright — see ToolDefinition.safe's own doc comment for why this is
@@ -280,21 +323,21 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
280
323
  messages.push(message);
281
324
  newMessages.push(message);
282
325
  }
283
- const reflow = new Reflow({
326
+ const recovery = new Recovery({
284
327
  onPromptTooLong: async (currentMessages) => {
285
328
  // newMessages (this turn's own content — not durably stored
286
- // anywhere else yet) must never be handed to ContextClip at all.
329
+ // anywhere else yet) must never be handed to the compactor at all.
287
330
  // An earlier version of this reconciliation tried to recover
288
- // newMessages *after* compaction by reusing whatever ContextClip's
289
- // synthetic head contained — that's unsound: ContextClip's cheap
331
+ // newMessages *after* compaction by reusing whatever the
332
+ // compactor's synthetic head contained — that's unsound: its cheap
290
333
  // "drain" stage doesn't produce a head at all, it just deletes old
291
334
  // messages outright, trusting there's a durable copy elsewhere.
292
335
  // That trust is only valid for the *prior* portion; only that
293
336
  // portion is safe to compact at all.
294
337
  const priorCount = currentMessages.length - newMessages.length;
295
338
  const priorPortion = currentMessages.slice(0, priorCount);
296
- const result = await contextClip.recover(priorPortion.map(flattenForContextClip));
297
- log('reflow:recover', { from: currentMessages.length, to: result.messages.length + newMessages.length });
339
+ const result = await compactor.recover(priorPortion.map(flattenForBudget));
340
+ log({ type: 'prompt:compaction', from: currentMessages.length, to: result.messages.length + newMessages.length });
298
341
  if (result.action === 'unchanged')
299
342
  return currentMessages;
300
343
  // Same tail-preservation reuse as before, just scoped to
@@ -304,13 +347,13 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
304
347
  const structuredTail = priorPortion.slice(priorPortion.length - preservedTailCount);
305
348
  const newHead = result.messages.slice(0, result.messages.length - preservedTailCount);
306
349
  const recovered = [...newHead, ...structuredTail, ...newMessages];
307
- // Reflow.call() only ever returns {value, recoveries, truncated} —
350
+ // Recovery.call() only ever returns {value, recoveries, truncated} —
308
351
  // it never hands back whatever `currentMessages` became after a
309
352
  // retry, so recovery would otherwise only ever affect the one
310
353
  // retried call. Reassigning the outer `messages` binding here (this
311
354
  // hook is the only place that has both the recovered array and a
312
355
  // reason to persist it) is what makes recovery durable: every
313
- // subsequent contextClip.check() in this loop, and the `history`
356
+ // subsequent budgetTracker.check() in this loop, and the `history`
314
357
  // this function eventually returns, both see the compacted
315
358
  // conversation from this point on — not the original, ever-growing
316
359
  // one. newMessages itself needs no reconciliation at all — it was
@@ -320,6 +363,19 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
320
363
  },
321
364
  });
322
365
  const toolsByName = new Map(tools.map((t) => [t.name, t]));
366
+ // Object identity, not name — an agent that overrides a system tool's
367
+ // name with its own ToolDefinition (dedupeToolsByName's own "config
368
+ // wins" rule above) is a deliberate opt-out, and that override should
369
+ // still go through the agent's own rules like anything else; only the
370
+ // genuine, unmodified system implementation always bypasses the gate.
371
+ // ask_user in particular can't be gated at all without a real deadlock:
372
+ // it's the mechanism a human uses to answer the agent, so requiring a
373
+ // human decision just to *ask* one is circular, not just redundant —
374
+ // and every default_decision this repo's own agents actually use
375
+ // ('ask' or 'deny', see loadRules' own doc comment) would otherwise
376
+ // catch it, since neither system tool ever appears in an agent's own
377
+ // actauth.yml.
378
+ const systemToolInstances = new Set([...systemTools, askUserTool]);
323
379
  const toolSchemas = tools.map(({ name, description, input_schema }) => ({
324
380
  name,
325
381
  description,
@@ -362,16 +418,21 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
362
418
  if (turn > maxTurns) {
363
419
  const text = `Stopped after ${maxTurns} turns without a final answer — the agent may be stuck in a loop.`;
364
420
  pushMessage({ role: 'assistant', content: text });
365
- log('loop:max_turns', { maxTurns });
421
+ log({ type: 'loop:max_turns', maxTurns });
366
422
  return { text, history: messages, newMessages, stopReason: 'max_turns' };
367
423
  }
368
- const budget = contextClip.check(messages.map(flattenForContextClip));
369
- log('contextclip:check', budget);
424
+ const budget = budgetTracker.check(messages.map(flattenForBudget));
425
+ log({ type: 'budget:check', ...budget });
370
426
  if (budget.action === 'nudge' && budget.nudge)
371
427
  pushMessage(budget.nudge);
372
- const { value: response, recoveries } = await reflow.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
373
- if (recoveries.length > 0)
374
- log('reflow:recoveries', recoveries);
428
+ // recovery.call()'s own return also includes `recoveries` (which of
429
+ // its three failure modes fired this call) and `truncated` — not
430
+ // used here: prompt:compaction above already reports the one
431
+ // recovery type that's actually wired to a real hook (onPromptTooLong),
432
+ // and there's no second type yet to justify a second, more generic
433
+ // event just to aggregate it (see PromptCompactionEvent's own doc
434
+ // comment for the removed 'recovery:summary').
435
+ const { value: response } = await recovery.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
375
436
  // The model's full response — text and tool_use blocks alike, with
376
437
  // real ids — becomes this turn's assistant message verbatim. Every
377
438
  // tool_use block emitted here gets a matching tool_result pushed
@@ -381,11 +442,34 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
381
442
  const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
382
443
  if (toolUseBlocks.length === 0) {
383
444
  const text = response.content.find((b) => b.type === 'text')?.text ?? '';
384
- log('loop:done', text);
445
+ log({ type: 'loop:done', text });
385
446
  return { text, history: messages, newMessages };
386
447
  }
448
+ // The model's own text alongside a tool_use request (its "I'll do X"
449
+ // preamble) is already durable via pushMessage above, but the only
450
+ // *live* SSE event that ever carries assistant text is 'done', which
451
+ // fires once at the very end of the whole turn — a live caller
452
+ // watching this turn in progress (adapters/http.ts's streaming route,
453
+ // the playground) would otherwise jump straight from "thinking..." to
454
+ // an approval/question card with zero explanation, and only ever see
455
+ // this sentence later, after a refresh replays stored history —
456
+ // confirmed live.
457
+ const preambleText = response.content.find((b) => b.type === 'text')?.text;
458
+ if (preambleText)
459
+ log({ type: 'assistant:text', text: preambleText });
387
460
  const resultBlocks = [];
388
461
  const approvedCalls = [];
462
+ const deniedTools = [];
463
+ // Keyed by tool_use id — approvedCalls (a toollane LaneCall) has no
464
+ // room for extra fields of its own, and toolLane.run's own results
465
+ // only carry `id`/`name`/status, not anything about the *decision*
466
+ // that let a call through in the first place (or what it was even
467
+ // called with). This is what lets the reason still get attached to
468
+ // the right tool_result once execution finishes below, and lets the
469
+ // 'tool:result' live event (see below) report the same args a
470
+ // resumed/refreshed view of this same call would show, without
471
+ // changing toollane's own types.
472
+ const approvedMetaById = new Map();
389
473
  for (const block of toolUseBlocks) {
390
474
  // Skill invocation injects instructions into context; it isn't a
391
475
  // real tool call and doesn't need ActAuth gating or ToolLane
@@ -393,13 +477,43 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
393
477
  // so it still needs a tool_result to answer it, same as any other.
394
478
  if (block.name === 'Skill' && skillGarden) {
395
479
  const body = skillGarden.invoke(block.input.skill, block.input?.args);
396
- log('skillgarden:invoke', block.input.skill);
480
+ log({ type: 'skill:loaded', skill: block.input.skill });
397
481
  resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
398
482
  continue;
399
483
  }
484
+ // See systemToolInstances' own doc comment above — bypasses
485
+ // gate.evaluate entirely, not just auto-approves through it, since
486
+ // ask_user itself can be the approver's own only way to ask a human
487
+ // anything in the first place.
488
+ if (systemToolInstances.has(toolsByName.get(block.name))) {
489
+ log({ type: 'actauth:decision', tool: block.name, decision: 'allow', reason: 'system tool — always allowed' });
490
+ approvedMetaById.set(block.id, { reason: 'system tool — always allowed', args: block.input ?? {} });
491
+ // Fired the moment the decision is in, not once execution finishes
492
+ // (see the 'tool:started' vs 'tool:result' split below) — a system
493
+ // tool can still be slow, and this is the one path that had no
494
+ // interactive card of its own to show something sooner.
495
+ log({ type: 'tool:started', id: block.id, tool: block.name, args: block.input ?? {}, detailText: 'system tool — always allowed' });
496
+ approvedCalls.push({
497
+ id: block.id,
498
+ name: block.name,
499
+ execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
500
+ });
501
+ continue;
502
+ }
400
503
  const decision = await gate.evaluate(block.name, block.input ?? {}, scope);
401
- log('actauth:decision', { tool: block.name, decision: decision.decision, reason: decision.reason });
504
+ log({ type: 'actauth:decision', tool: block.name, decision: decision.decision, reason: decision.reason });
402
505
  if (decision.decision === 'allow') {
506
+ approvedMetaById.set(block.id, { reason: decision.reason, args: block.input ?? {} });
507
+ // Fired the instant the decision is in, not once execution
508
+ // finishes below — closes what would otherwise be a multi-second
509
+ // silent gap while a slow call runs. Always emitted, whether or
510
+ // not a human was actually asked live: run-agent.ts itself has no
511
+ // notion of "already shown elsewhere" — that's a rendering
512
+ // decision, and belongs to whichever channel adapter is actually
513
+ // presenting this turn (see web/playground.ts's own
514
+ // wasAskedInteractively, which decides whether this duplicates an
515
+ // approval card already on screen), not to the engine loop.
516
+ log({ type: 'tool:started', id: block.id, tool: block.name, args: block.input ?? {}, detailText: decision.reason });
403
517
  approvedCalls.push({
404
518
  id: block.id,
405
519
  name: block.name,
@@ -410,31 +524,99 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
410
524
  // Every requested tool gets exactly one tool_result back — a
411
525
  // denied call is not simply dropped, or the model has no way to
412
526
  // tell "denied" apart from "hasn't run yet" and may just
413
- // re-request it forever.
527
+ // re-request it forever. Recorded even though this turn is about
528
+ // to stop (see deniedTools below) — a later message continuing
529
+ // this same session still needs a complete, consistent history,
530
+ // not a dangling tool_use with no answer.
531
+ deniedTools.push(block.name);
414
532
  resultBlocks.push({
415
533
  type: 'tool_result',
416
534
  tool_use_id: block.id,
417
535
  content: `denied: ${decision.reason}`,
418
536
  is_error: true,
419
537
  });
538
+ // Always emitted — see the 'allow' branch's own comment above on
539
+ // why this engine loop no longer decides whether a live UI
540
+ // already has some other representation of this decision on
541
+ // screen (an approval card that went through an interactive
542
+ // ask). That's a rendering call, made by whichever channel
543
+ // adapter is presenting this turn.
544
+ log({ type: 'tool:result', id: block.id, tool: block.name, args: block.input ?? {}, detailText: decision.reason, statusText: 'Denied.' });
545
+ }
546
+ }
547
+ if (deniedTools.length > 0) {
548
+ // A denial cancels the *whole* batch, not just the call(s) that
549
+ // were themselves denied — an approved sibling call from the same
550
+ // turn never runs at all, same reasoning Claude Code itself stops
551
+ // an entire pending batch rather than quietly carrying out the
552
+ // parts you didn't object to. Every approved call still gets
553
+ // exactly one tool_result back regardless (the model emitted a
554
+ // tool_use for it, so it needs an answer) — "skipped", not
555
+ // "denied": it was never evaluated against its own rule, a
556
+ // *different* call in the same turn was what stopped it.
557
+ for (const call of approvedCalls) {
558
+ log({ type: 'loop:skipped', name: call.name, deniedTools });
559
+ const skipReason = `a sibling tool call in this turn (${deniedTools.join(', ')}) was denied`;
560
+ resultBlocks.push({
561
+ type: 'tool_result',
562
+ tool_use_id: call.id,
563
+ content: `skipped: ${skipReason}`,
564
+ is_error: true,
565
+ });
566
+ // A skipped call never went through gate.evaluate at all — no
567
+ // approver, no interactive card, nothing live ever showed it was
568
+ // even requested. Same reasoning as the straight-denied case
569
+ // just above.
570
+ log({ type: 'tool:result', id: call.id, tool: call.name, args: approvedMetaById.get(call.id)?.args, detailText: skipReason, statusText: 'Skipped.' });
420
571
  }
421
572
  }
422
- // result.id is the same id LaneCall.id was given above — the exact
423
- // per-call identity that makes it possible to link a result back to
424
- // the specific tool_use block that requested it, even when several
425
- // calls ran in the same parallel lane. The old flattened-text design
426
- // could only link by tool *name*, ambiguous the moment two calls to
427
- // the same tool ran in one turn.
428
- for await (const result of toolLane.run(approvedCalls)) {
429
- const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
430
- log('toollane:result', { name: result.name, summary });
431
- resultBlocks.push({
432
- type: 'tool_result',
433
- tool_use_id: result.id,
434
- content: summary,
435
- is_error: result.status === 'rejected',
436
- });
573
+ else {
574
+ // result.id is the same id LaneCall.id was given above — the exact
575
+ // per-call identity that makes it possible to link a result back
576
+ // to the specific tool_use block that requested it, even when
577
+ // several calls ran in the same parallel lane. The old
578
+ // flattened-text design could only link by tool *name*, ambiguous
579
+ // the moment two calls to the same tool ran in one turn.
580
+ for await (const result of toolLane.run(approvedCalls)) {
581
+ const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
582
+ const meta = approvedMetaById.get(result.id);
583
+ log({ type: 'toollane:result', name: result.name, summary });
584
+ resultBlocks.push({
585
+ type: 'tool_result',
586
+ tool_use_id: result.id,
587
+ content: summary,
588
+ is_error: result.status === 'rejected',
589
+ reason: meta?.reason,
590
+ });
591
+ // Always emitted — this is what turns the earlier 'tool:started'
592
+ // event's "Running…" placeholder into the real outcome (matched
593
+ // by the same id), for every call regardless of how it got
594
+ // approved. See the 'allow' branch's own comment above: deciding
595
+ // whether this duplicates an approval card already on screen is
596
+ // a rendering call for the channel adapter presenting this turn,
597
+ // not something this engine loop tracks.
598
+ log({
599
+ type: 'tool:result',
600
+ id: result.id,
601
+ tool: result.name,
602
+ args: meta?.args,
603
+ detailText: meta?.reason,
604
+ statusText: result.status === 'fulfilled' ? 'Approved.' : 'Error.',
605
+ });
606
+ }
437
607
  }
438
608
  pushMessage({ role: 'user', content: resultBlocks });
609
+ // Stop here, don't loop back for another model call — same reasoning
610
+ // Claude Code itself stops and waits for you rather than working
611
+ // around a tool call you just rejected, instead of silently handing
612
+ // "denied: ..." to the model and letting it decide on its own what to
613
+ // try next (retry the same tool, reach for a different one, or just
614
+ // talk its way past the refusal).
615
+ if (deniedTools.length > 0) {
616
+ const text = `Stopped — you denied: ${deniedTools.join(', ')}. Send another message to continue.`;
617
+ pushMessage({ role: 'assistant', content: text });
618
+ log({ type: 'loop:denied', deniedTools });
619
+ return { text, history: messages, newMessages, stopReason: 'denied' };
620
+ }
439
621
  }
440
622
  }
@@ -1,12 +1,12 @@
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
7
7
  * RunAgentResult.newMessages) rather than being inferred by diffing a
8
8
  * returned `history` against what withSession loaded: that only works
9
- * if history only ever grows, which isn't true once ContextClip
9
+ * if history only ever grows, which isn't true once compaction.ts's own
10
10
  * recovery can shrink/rewrite it mid-turn — see RunAgentResult's own
11
11
  * doc comment for the full reasoning. */
12
12
  newMessages: Message[];
@@ -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;