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.
- package/README.md +67 -6
- package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/{cli.d.ts → bin/cli.d.ts} +27 -6
- package/dist/bin/cli.js +384 -0
- package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
- package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
- package/dist/core/client.d.ts +113 -0
- package/dist/core/client.js +123 -0
- package/dist/core/discover-agents.d.ts +45 -0
- package/dist/{discover-agents.js → core/discover-agents.js} +12 -4
- package/dist/core/gateway-tools.d.ts +226 -0
- package/dist/core/gateway-tools.js +660 -0
- package/dist/core/loop-events.d.ts +168 -0
- package/dist/core/loop-events.js +1 -0
- package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
- package/dist/core/run-agent.d.ts +182 -0
- package/dist/{run-agent.js → core/run-agent.js} +233 -51
- package/dist/{session-store.d.ts → core/session-store.d.ts} +13 -1
- package/dist/{session-store.js → core/session-store.js} +19 -0
- package/dist/core/system-tools/ask_user.d.ts +72 -0
- package/dist/core/system-tools/ask_user.js +136 -0
- package/dist/core/system-tools/index.d.ts +3 -0
- package/dist/core/system-tools/index.js +12 -0
- package/dist/core/system-tools/read_file.d.ts +28 -0
- package/dist/core/system-tools/read_file.js +64 -0
- package/dist/index.d.ts +19 -16
- package/dist/index.js +16 -9
- package/package.json +33 -25
- package/dist/cli.js +0 -191
- package/dist/discover-agents.d.ts +0 -24
- package/dist/run-agent.d.ts +0 -92
- /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
- /package/dist/{agent-config.js → core/agent-config.js} +0 -0
- /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
- /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
- /package/dist/{model-calls → core/model-calls}/openai-model-call.js +0 -0
- /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
- /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
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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/*
|
|
233
|
-
//
|
|
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
|
-
|
|
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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
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 =
|
|
246
|
-
const skillIndex = skillGarden
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
type: '
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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;
|