@animalabs/agent-framework 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/src/agent.d.ts +22 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +128 -10
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/types.d.ts +1 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +10 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +27 -2
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +21 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +34 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +153 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1175 -76
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +49 -5
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +59 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +106 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +8 -6
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +12 -6
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +3 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +318 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
- package/dist/src/mcpl/push-coalescer.js +709 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +7 -2
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +43 -4
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +9 -4
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +44 -1
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +71 -0
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
- package/dist/src/mcpl/tool-classes.js +170 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -0
- package/dist/src/mcpl/tool-glob.d.ts +16 -0
- package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
- package/dist/src/mcpl/tool-glob.js +41 -0
- package/dist/src/mcpl/tool-glob.js.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.js +607 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +15 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +65 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +68 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +6 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +85 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +1019 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +202 -0
- package/dist/src/modules/history/semantic.d.ts.map +1 -0
- package/dist/src/modules/history/semantic.js +418 -0
- package/dist/src/modules/history/semantic.js.map +1 -0
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +82 -0
- package/dist/src/tool-result-guard.d.ts.map +1 -0
- package/dist/src/tool-result-guard.js +306 -0
- package/dist/src/tool-result-guard.js.map +1 -0
- package/dist/src/types/agent.d.ts +14 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +21 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +17 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +59 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/code-execution.test.js +139 -0
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/conversation-router.test.js +33 -19
- package/dist/test/conversation-router.test.js.map +1 -1
- package/dist/test/conversation-routing.test.js +89 -0
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-fixture.js +145 -0
- package/dist/test/helpers/coalescing-fixture.js.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.js +24 -0
- package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
- package/dist/test/history-module-realstore.test.d.ts +2 -0
- package/dist/test/history-module-realstore.test.d.ts.map +1 -0
- package/dist/test/history-module-realstore.test.js +363 -0
- package/dist/test/history-module-realstore.test.js.map +1 -0
- package/dist/test/history-module-semantic.test.d.ts +2 -0
- package/dist/test/history-module-semantic.test.d.ts.map +1 -0
- package/dist/test/history-module-semantic.test.js +213 -0
- package/dist/test/history-module-semantic.test.js.map +1 -0
- package/dist/test/history-module-ux.test.d.ts +2 -0
- package/dist/test/history-module-ux.test.d.ts.map +1 -0
- package/dist/test/history-module-ux.test.js +304 -0
- package/dist/test/history-module-ux.test.js.map +1 -0
- package/dist/test/history-module.test.js +25 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
- package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
- package/dist/test/history-semantic-review-findings.test.js +338 -0
- package/dist/test/history-semantic-review-findings.test.js.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.js +109 -0
- package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +3 -1
- package/dist/test/mcpl-capability-grant.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.d.ts +2 -0
- package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
- package/dist/test/mcpl-child-env.test.js +158 -0
- package/dist/test/mcpl-child-env.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.d.ts +2 -0
- package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +1050 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -0
- package/dist/test/mcpl-list-status.test.js +4 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.d.ts +2 -0
- package/dist/test/mcpl-reference-fields.test.d.ts.map +1 -0
- package/dist/test/mcpl-reference-fields.test.js +82 -0
- package/dist/test/mcpl-reference-fields.test.js.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.js +147 -0
- package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
- package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
- package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-classes.test.js +80 -0
- package/dist/test/mcpl-tool-classes.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.d.ts +2 -0
- package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat.test.js +154 -0
- package/dist/test/silent-heartbeat.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.d.ts +2 -0
- package/dist/test/tool-result-guard.test.d.ts.map +1 -0
- package/dist/test/tool-result-guard.test.js +1081 -0
- package/dist/test/tool-result-guard.test.js.map +1 -0
- package/dist/test/tools-observe-parser-conformance.test.d.ts +2 -0
- package/dist/test/tools-observe-parser-conformance.test.d.ts.map +1 -0
- package/dist/test/tools-observe-parser-conformance.test.js +129 -0
- package/dist/test/tools-observe-parser-conformance.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +67 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +128 -10
- package/src/api/types.ts +2 -0
- package/src/code-execution/py-runner.ts +34 -2
- package/src/code-execution/tool-definition.ts +46 -0
- package/src/framework.ts +1207 -80
- package/src/index.ts +13 -0
- package/src/mcpl/capability-grant.ts +54 -6
- package/src/mcpl/channel-registry.ts +115 -7
- package/src/mcpl/conversation-router.ts +12 -8
- package/src/mcpl/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- package/src/mcpl/references.ts +11 -3
- package/src/mcpl/server-connection.ts +48 -2
- package/src/mcpl/tool-classes.ts +188 -0
- package/src/mcpl/tool-glob.ts +36 -0
- package/src/mcpl/tool-lifecycle.ts +745 -0
- package/src/mcpl/transport.ts +67 -1
- package/src/mcpl/types.ts +71 -1
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +1089 -24
- package/src/modules/history/semantic.ts +473 -0
- package/src/modules/index.ts +2 -1
- package/src/tool-result-guard.ts +318 -0
- package/src/types/agent.ts +15 -0
- package/src/types/framework.ts +23 -0
- package/src/types/trace.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animalabs/agent-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"license": "MIT",
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@animalabs/chronicle": "^0.4.0",
|
|
50
|
-
"@animalabs/context-manager": "^0.
|
|
50
|
+
"@animalabs/context-manager": "^0.13.0",
|
|
51
51
|
"@animalabs/membrane": "^0.5.78",
|
|
52
52
|
"chokidar": "^4.0.3",
|
|
53
53
|
"discord.js": "^14.25.1",
|
package/src/agent.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Membrane, NormalizedMessage, NormalizedRequest, ContentBlock, Yiel
|
|
|
2
2
|
import { isAbortedResponse } from '@animalabs/membrane';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import type { CacheWireReceipt, KvUnifiedRequestHooks } from './kv-unified-wire.js';
|
|
5
|
+
import { ToolResultGuard, TOOL_RESULT_GUARD_NOTICE } from './tool-result-guard.js';
|
|
5
6
|
import {
|
|
6
7
|
toolResultDataToHistoryString,
|
|
7
8
|
truncateForHistory,
|
|
@@ -82,6 +83,7 @@ export class Agent {
|
|
|
82
83
|
readonly thinking: AgentConfig['thinking'];
|
|
83
84
|
/** Refusal auto-rewind policy (see AgentConfig.refusalHandling). */
|
|
84
85
|
readonly refusalHandling: AgentConfig['refusalHandling'];
|
|
86
|
+
readonly toolResultGuard: ToolResultGuard;
|
|
85
87
|
/** Prose delivery mode (see AgentConfig.proseRouting). Default 'locus'. */
|
|
86
88
|
readonly proseRouting: 'locus' | 'explicit' | 'hybrid' | 'disabled';
|
|
87
89
|
/** Exact whole-response known-tool wrapper containment (default off). */
|
|
@@ -143,6 +145,7 @@ export class Agent {
|
|
|
143
145
|
this.temperature = config.temperature;
|
|
144
146
|
this.thinking = config.thinking;
|
|
145
147
|
this.refusalHandling = config.refusalHandling;
|
|
148
|
+
this.toolResultGuard = new ToolResultGuard(config.name, contextManager, config.toolResultGuard);
|
|
146
149
|
this.proseRouting = config.proseRouting ?? 'locus';
|
|
147
150
|
this.toolWrapperProseGuard = config.toolWrapperProseGuard ?? false;
|
|
148
151
|
this.cacheTtl = config.cacheTtl ?? '1h';
|
|
@@ -225,6 +228,20 @@ export class Agent {
|
|
|
225
228
|
return { maxTokens: this.contextBudgetTokens, reserveForResponse: this.maxTokens };
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
/** Compile budget with the tool-result guard's reservation applied. While
|
|
232
|
+
* a batch is pending the strategy selects against short placeholders, but
|
|
233
|
+
* prepareRequest substitutes the originals on the wire; reserve their real
|
|
234
|
+
* cost so the final request still fits (a budget restart that recompiles
|
|
235
|
+
* small must not re-inflate past the same window). */
|
|
236
|
+
private compileBudget(budget?: TokenBudget): TokenBudget | undefined {
|
|
237
|
+
const resolved = this.resolveBudget(budget);
|
|
238
|
+
const reserve = this.toolResultGuard.pendingWireReserveTokens;
|
|
239
|
+
if (reserve === 0) return resolved;
|
|
240
|
+
// Mirrors ContextManager.compile's own default when no budget is set.
|
|
241
|
+
const base = resolved ?? { maxTokens: 100_000, reserveForResponse: 4_000 };
|
|
242
|
+
return { ...base, reserveForResponse: base.reserveForResponse + reserve };
|
|
243
|
+
}
|
|
244
|
+
|
|
228
245
|
/** Structural compatibility keeps lightweight test/host ContextManager
|
|
229
246
|
* doubles working while making the new capability optional at runtime. */
|
|
230
247
|
private getHotContextSettings(): HotContextSettingsStatus | null {
|
|
@@ -522,8 +539,50 @@ export class Agent {
|
|
|
522
539
|
this.updateHotContextSettings({ preparedWindowTokens: null });
|
|
523
540
|
}
|
|
524
541
|
|
|
542
|
+
/**
|
|
543
|
+
* RFC-006 consumed watermark: the newest stored message at the last compile
|
|
544
|
+
* of a model request, per branch. Everything above it has never been in a
|
|
545
|
+
* request and may still be replaced or withdrawn in place by its sender.
|
|
546
|
+
*/
|
|
547
|
+
private consumedWatermark: { branch: string; sequence: number } | null = null;
|
|
548
|
+
|
|
549
|
+
getConsumedWatermark(): { branch: string; sequence: number } | null {
|
|
550
|
+
return this.consumedWatermark;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Mark everything currently stored as consumed (boot, branch switch). */
|
|
554
|
+
markContextConsumed(): void {
|
|
555
|
+
// Tolerates partial context-manager doubles (tests build Agents over
|
|
556
|
+
// stubs): without a watermark nothing is ever treated as unread.
|
|
557
|
+
this.consumedWatermark = this.pendingWatermark();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The watermark a successful compile will establish (taken before, applied after). */
|
|
561
|
+
private pendingWatermark(): { branch: string; sequence: number } | null {
|
|
562
|
+
const cm = this.contextManager as Partial<ContextManager>;
|
|
563
|
+
try {
|
|
564
|
+
if (typeof cm.currentBranch !== 'function' || typeof cm.getAllMessages !== 'function') return null;
|
|
565
|
+
// The chronicle head, not this slot's last message: a compile reads
|
|
566
|
+
// everything stored on the branch so far, including messages merged in
|
|
567
|
+
// from another slot (the subconscious reads the residents' shared slot).
|
|
568
|
+
const store = typeof cm.getStore === 'function' ? cm.getStore() as { currentSequence?: () => number } : undefined;
|
|
569
|
+
let sequence = typeof store?.currentSequence === 'function' ? store.currentSequence() : undefined;
|
|
570
|
+
if (sequence === undefined) {
|
|
571
|
+
const count = typeof cm.getMessageCount === 'function' ? cm.getMessageCount() : cm.getAllMessages().length;
|
|
572
|
+
sequence = count > 0 ? cm.getAllMessages().at(-1)?.sequence ?? 0 : 0;
|
|
573
|
+
}
|
|
574
|
+
return { branch: cm.currentBranch().name, sequence };
|
|
575
|
+
} catch {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
525
580
|
async compileContext(budget?: TokenBudget): Promise<CompileResult> {
|
|
526
|
-
|
|
581
|
+
// RFC-006 §3.3: consumption is determined at assembly — a compile that
|
|
582
|
+
// fails assembled nothing, so the watermark moves only on success.
|
|
583
|
+
const watermark = this.pendingWatermark();
|
|
584
|
+
const result = await this.contextManager.compile(this.compileBudget(budget));
|
|
585
|
+
if (watermark) this.consumedWatermark = watermark;
|
|
527
586
|
if (!budget) this.settleRuntimeSettingsTransition();
|
|
528
587
|
return result;
|
|
529
588
|
}
|
|
@@ -538,9 +597,11 @@ export class Agent {
|
|
|
538
597
|
injections?: ContextInjection[],
|
|
539
598
|
opts?: { kvUnifiedImmutablePrefixHash?: string },
|
|
540
599
|
): Promise<CompileResult> {
|
|
600
|
+
const watermark = this.pendingWatermark();
|
|
541
601
|
const result = await this.contextManager.compile(
|
|
542
|
-
this.
|
|
602
|
+
this.compileBudget(budget), injections, opts as never,
|
|
543
603
|
);
|
|
604
|
+
if (watermark) this.consumedWatermark = watermark;
|
|
544
605
|
if (!budget) this.settleRuntimeSettingsTransition();
|
|
545
606
|
return result;
|
|
546
607
|
}
|
|
@@ -579,20 +640,33 @@ export class Agent {
|
|
|
579
640
|
throw new Error(`Agent ${this.name} is waiting for tool results`);
|
|
580
641
|
}
|
|
581
642
|
|
|
643
|
+
this.toolResultGuard.flushUnrecorded();
|
|
644
|
+
|
|
582
645
|
// Filter tools to only allowed ones
|
|
583
646
|
const tools = availableTools.filter((t) => this.canUseTool(t.name));
|
|
584
647
|
|
|
648
|
+
// The direct Agent API uses the same admission rules as framework-driven
|
|
649
|
+
// streams. Stage before compiling so compression only sees placeholders.
|
|
650
|
+
const guardedResults = this._state.status === 'ready' && this.toolResultGuard.enabled;
|
|
651
|
+
if (guardedResults && this._state.status === 'ready') {
|
|
652
|
+
const content = this.buildToolResultMessages(this._state.toolResults)[0].content;
|
|
653
|
+
const wireResults = content.flatMap((block) => block.type === 'tool_result'
|
|
654
|
+
// buildToolResultMessages serializes every payload to a string.
|
|
655
|
+
? [{ toolUseId: block.toolUseId, content: block.content as string, isError: block.isError }] : []);
|
|
656
|
+
this.toolResultGuard.storeResults(content, wireResults, this._state.toolResults);
|
|
657
|
+
}
|
|
658
|
+
|
|
585
659
|
// Compile context (with optional injections)
|
|
586
660
|
const { messages, systemInjections } = await this.compileWithInjections(budget, injections);
|
|
587
661
|
|
|
588
662
|
// If we have pending tool results, add them
|
|
589
|
-
if (this._state.status === 'ready') {
|
|
663
|
+
if (this._state.status === 'ready' && !guardedResults) {
|
|
590
664
|
const toolResultMessages = this.buildToolResultMessages(this._state.toolResults);
|
|
591
665
|
messages.push(...toolResultMessages);
|
|
592
666
|
}
|
|
593
667
|
|
|
594
668
|
const request: NormalizedRequest = {
|
|
595
|
-
messages,
|
|
669
|
+
messages: this.toolResultGuard.prepareRequest(messages, true),
|
|
596
670
|
system: this.buildSystemPrompt(systemInjections),
|
|
597
671
|
config: {
|
|
598
672
|
model: this.model,
|
|
@@ -648,6 +722,7 @@ export class Agent {
|
|
|
648
722
|
} catch (error) {
|
|
649
723
|
// On error, go back to idle
|
|
650
724
|
this._state = { status: 'idle' };
|
|
725
|
+
this.toolResultGuard.recovering = false;
|
|
651
726
|
throw error;
|
|
652
727
|
}
|
|
653
728
|
}
|
|
@@ -773,7 +848,7 @@ export class Agent {
|
|
|
773
848
|
}
|
|
774
849
|
|
|
775
850
|
return {
|
|
776
|
-
messages,
|
|
851
|
+
messages: this.toolResultGuard.prepareRequest(messages),
|
|
777
852
|
system: this.buildSystemPrompt(systemInjections),
|
|
778
853
|
config: {
|
|
779
854
|
model: this.model,
|
|
@@ -804,6 +879,10 @@ export class Agent {
|
|
|
804
879
|
throw new Error(`Agent ${this.name} cannot start stream in state ${this._state.status}`);
|
|
805
880
|
}
|
|
806
881
|
|
|
882
|
+
// Retry accepted history edits before assembling a real request. Pure
|
|
883
|
+
// preview/compile entry points must not perform these writes.
|
|
884
|
+
this.toolResultGuard.flushUnrecorded();
|
|
885
|
+
|
|
807
886
|
// A new activation supersedes every receipt flight the previous one left
|
|
808
887
|
// open. Membrane fires the kv-unified wire receipt per provider attempt,
|
|
809
888
|
// BEFORE the adapter call; only that attempt's usage event settles it.
|
|
@@ -830,6 +909,7 @@ export class Agent {
|
|
|
830
909
|
this.lastStreamOutputTokens = 0;
|
|
831
910
|
|
|
832
911
|
const request = await this.buildActivationRequest(availableTools, injections, budget);
|
|
912
|
+
request.messages = this.toolResultGuard.prepareRequest(request.messages, true);
|
|
833
913
|
|
|
834
914
|
const receiptAware = (this.contextManager as unknown as { getStrategy?: () => unknown })
|
|
835
915
|
.getStrategy?.() as {
|
|
@@ -854,6 +934,7 @@ export class Agent {
|
|
|
854
934
|
};
|
|
855
935
|
}
|
|
856
936
|
|
|
937
|
+
const agent = this;
|
|
857
938
|
const stream = this.membrane.streamYielding(request, {
|
|
858
939
|
emitTokens: true,
|
|
859
940
|
emitBlocks: false,
|
|
@@ -863,7 +944,13 @@ export class Agent {
|
|
|
863
944
|
// (cache-warm), where a framework-level requeue would recompile and
|
|
864
945
|
// land a different window. The framework's driveStream handles the
|
|
865
946
|
// resulting `retrying` event by discarding the abandoned attempt.
|
|
866
|
-
|
|
947
|
+
// Membrane reads this per physical round, including after settings
|
|
948
|
+
// tools run. A guarded result must reach the host on its FIRST refusal;
|
|
949
|
+
// never spend retries resubmitting unchanged guarded content.
|
|
950
|
+
get refusalRetries() {
|
|
951
|
+
return agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering
|
|
952
|
+
? 0 : agent.refusalHandling?.retries ?? 0;
|
|
953
|
+
},
|
|
867
954
|
});
|
|
868
955
|
|
|
869
956
|
this._state = { status: 'streaming', stream };
|
|
@@ -1041,9 +1128,32 @@ export class Agent {
|
|
|
1041
1128
|
request: NormalizedRequest,
|
|
1042
1129
|
signal?: AbortSignal
|
|
1043
1130
|
): Promise<InferenceResult> {
|
|
1044
|
-
|
|
1131
|
+
let response = await this.membrane.stream(request, { signal });
|
|
1132
|
+
// Usage of a refused attempt abandoned by the guard; it was billed and
|
|
1133
|
+
// is added to the retry's usage below rather than overwritten.
|
|
1134
|
+
let abandonedUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
1135
|
+
if (!isAbortedResponse(response) && response.stopReason === 'refusal') {
|
|
1136
|
+
// A staged batch never on the wire cannot have caused the refusal.
|
|
1137
|
+
this.toolResultGuard.settleUnsubmitted('unknown');
|
|
1138
|
+
const ids = this.toolResultGuard.withhold('unknown');
|
|
1139
|
+
if (ids) {
|
|
1140
|
+
abandonedUsage = response.usage;
|
|
1141
|
+
const withheld = new Set(ids);
|
|
1142
|
+
request = { ...request, messages: request.messages.map((message) => ({
|
|
1143
|
+
...message, content: message.content.map((block) => block.type === 'tool_result' && withheld.has(block.toolUseId)
|
|
1144
|
+
? { type: 'tool_result', toolUseId: block.toolUseId, content: TOOL_RESULT_GUARD_NOTICE, isError: block.isError } : block),
|
|
1145
|
+
})) };
|
|
1146
|
+
response = await this.membrane.stream(request, { signal });
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1045
1149
|
|
|
1046
1150
|
if (isAbortedResponse(response)) {
|
|
1151
|
+
// Interrupted before a clean response: settle, never leave pending.
|
|
1152
|
+
try {
|
|
1153
|
+
this.toolResultGuard.abandon('aborted');
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
console.error(`[tool-result-guard] agent=${this.name} failed to record aborted batch:`, error);
|
|
1156
|
+
}
|
|
1047
1157
|
const partialContent = response.partialContent ?? [];
|
|
1048
1158
|
const { toolCalls, speechContent } = this.extractToolCallsAndSpeech(partialContent);
|
|
1049
1159
|
return {
|
|
@@ -1056,16 +1166,24 @@ export class Agent {
|
|
|
1056
1166
|
};
|
|
1057
1167
|
}
|
|
1058
1168
|
|
|
1059
|
-
const
|
|
1169
|
+
const guardedRefusal = response.stopReason === 'refusal' && this.toolResultGuard.recovering;
|
|
1170
|
+
if (response.stopReason !== 'refusal') this.toolResultGuard.accept();
|
|
1171
|
+
this.toolResultGuard.recovering = false;
|
|
1172
|
+
const content = guardedRefusal ? [] : response.content;
|
|
1173
|
+
const { toolCalls, speechContent } = this.extractToolCallsAndSpeech(content);
|
|
1060
1174
|
|
|
1061
1175
|
// Add assistant response to context
|
|
1062
|
-
this.contextManager.addMessage(this.name,
|
|
1176
|
+
if (!guardedRefusal) this.contextManager.addMessage(this.name, content);
|
|
1063
1177
|
|
|
1064
1178
|
return {
|
|
1065
1179
|
toolCalls,
|
|
1066
1180
|
speechContent,
|
|
1067
1181
|
raw: response.raw,
|
|
1068
|
-
usage: response.usage
|
|
1182
|
+
usage: abandonedUsage && response.usage ? {
|
|
1183
|
+
...response.usage,
|
|
1184
|
+
inputTokens: response.usage.inputTokens + abandonedUsage.inputTokens,
|
|
1185
|
+
outputTokens: response.usage.outputTokens + abandonedUsage.outputTokens,
|
|
1186
|
+
} : response.usage,
|
|
1069
1187
|
stopReason: response.stopReason,
|
|
1070
1188
|
};
|
|
1071
1189
|
}
|
package/src/api/types.ts
CHANGED
|
@@ -77,12 +77,24 @@ const DEFAULT_SCRIPT_TIMEOUT_MS = 600_000;
|
|
|
77
77
|
const DEFAULT_IDLE_RECLAIM_MS = 300_000;
|
|
78
78
|
const CANCEL_GRACE_MS = 10_000;
|
|
79
79
|
|
|
80
|
+
/** The longest delay Node's timers honour (~24.8 days); a longer one fires after ~1 ms. */
|
|
81
|
+
export const MAX_TIMER_MS = 2_147_483_647;
|
|
82
|
+
|
|
83
|
+
/** A time limit for a message: "45s", "10 min", "2.5 h". */
|
|
84
|
+
export function formatLimit(ms: number): string {
|
|
85
|
+
if (ms < 120_000) return `${Math.round(ms / 1000)}s`;
|
|
86
|
+
if (ms < 7_200_000) return `${Math.round(ms / 6_000) / 10} min`;
|
|
87
|
+
return `${Math.round(ms / 360_000) / 10} h`;
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
interface PendingExec {
|
|
81
91
|
id: string;
|
|
82
92
|
resolve: (result: ExecResult) => void;
|
|
83
93
|
deadlineTimer: ReturnType<typeof setTimeout> | null;
|
|
84
94
|
killTimer: ReturnType<typeof setTimeout> | null;
|
|
85
95
|
settled: boolean;
|
|
96
|
+
/** Set once the deadline fired: the result then says the script ran out of time. */
|
|
97
|
+
deadlineMs: number | null;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
export class PyRunner {
|
|
@@ -125,8 +137,16 @@ export class PyRunner {
|
|
|
125
137
|
* log file, wake_agent() is available in-script, and the deadline is the
|
|
126
138
|
* background lifetime. A background runner should be DEDICATED to that one
|
|
127
139
|
* script (the framework creates one per background script).
|
|
140
|
+
*
|
|
141
|
+
* `opts.deadlineMs` replaces the runner's scriptTimeoutMs for this exec (a
|
|
142
|
+
* per-call time limit); a background exec uses its `lifetimeMs` instead.
|
|
128
143
|
*/
|
|
129
|
-
async exec(
|
|
144
|
+
async exec(
|
|
145
|
+
code: string,
|
|
146
|
+
tools: InjectedTool[],
|
|
147
|
+
background?: BackgroundExecOptions,
|
|
148
|
+
opts?: { deadlineMs?: number },
|
|
149
|
+
): Promise<ExecResult> {
|
|
130
150
|
if (this.disposed) {
|
|
131
151
|
return { stdout: '', stderr: 'code_execution runner disposed', returnCode: 1, aborted: true };
|
|
132
152
|
}
|
|
@@ -154,7 +174,8 @@ export class PyRunner {
|
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
const execId = `e${++this.execCounter}`;
|
|
157
|
-
|
|
177
|
+
// Clamped: a deadline past Node's timer range would fire at once instead of never.
|
|
178
|
+
const deadlineMs = Math.min(MAX_TIMER_MS, background?.lifetimeMs ?? opts?.deadlineMs ?? this.scriptTimeoutMs);
|
|
158
179
|
this.onWake = background?.onWake ?? null;
|
|
159
180
|
const result = await new Promise<ExecResult>((resolve) => {
|
|
160
181
|
const pending: PendingExec = {
|
|
@@ -163,14 +184,17 @@ export class PyRunner {
|
|
|
163
184
|
deadlineTimer: null,
|
|
164
185
|
killTimer: null,
|
|
165
186
|
settled: false,
|
|
187
|
+
deadlineMs: null,
|
|
166
188
|
};
|
|
167
189
|
this.pending = pending;
|
|
168
190
|
|
|
169
191
|
pending.deadlineTimer = setTimeout(() => {
|
|
192
|
+
pending.deadlineMs = deadlineMs;
|
|
170
193
|
// Deadline: ask politely first (script sees CancelledError and its
|
|
171
194
|
// exec_result still flows back), then kill on unresponsiveness.
|
|
172
195
|
this.send({ op: 'cancel', id: execId, reason: 'deadline' });
|
|
173
196
|
pending.killTimer = setTimeout(() => {
|
|
197
|
+
pending.deadlineMs = null; // this message already says why
|
|
174
198
|
this.settlePending({
|
|
175
199
|
stdout: '',
|
|
176
200
|
stderr: `script killed after exceeding ${Math.round(deadlineMs / 1000)}s deadline`,
|
|
@@ -380,6 +404,14 @@ export class PyRunner {
|
|
|
380
404
|
const pending = this.pending;
|
|
381
405
|
if (!pending || pending.settled) return;
|
|
382
406
|
pending.settled = true;
|
|
407
|
+
// Say why the script stopped: the in-script cancellation only reads "cancelled by host".
|
|
408
|
+
// Only a script that ended unsuccessfully was stopped: one that caught the cancellation
|
|
409
|
+
// and finished its work returns 0, and must not read as stopped.
|
|
410
|
+
if (pending.deadlineMs !== null && result.returnCode !== 0) {
|
|
411
|
+
const note = `script stopped: it reached its ${formatLimit(pending.deadlineMs)} time limit\n`;
|
|
412
|
+
// A background script reports its output tail, so the note goes there too.
|
|
413
|
+
result = { ...result, stderr: result.stderr + note, ...(result.tail !== undefined ? { tail: result.tail + note } : {}) };
|
|
414
|
+
}
|
|
383
415
|
if (pending.deadlineTimer) clearTimeout(pending.deadlineTimer);
|
|
384
416
|
if (pending.killTimer) clearTimeout(pending.killTimer);
|
|
385
417
|
this.pending = null;
|
|
@@ -9,15 +9,20 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ToolDefinition } from '../types/index.js';
|
|
12
|
+
import { formatLimit, MAX_TIMER_MS } from './py-runner.js';
|
|
12
13
|
|
|
13
14
|
export const CODE_EXECUTION_TOOL_NAME = 'code_execution';
|
|
14
15
|
|
|
15
16
|
export function buildCodeExecutionToolDefinition(opts?: {
|
|
16
17
|
idleReclaimMs?: number;
|
|
17
18
|
toolCallTimeoutMs?: number;
|
|
19
|
+
scriptTimeoutMs?: number;
|
|
20
|
+
maxScriptTimeoutMs?: number;
|
|
21
|
+
backgroundMaxLifetimeMs?: number;
|
|
18
22
|
}): ToolDefinition {
|
|
19
23
|
const idleMinutes = Math.max(1, Math.round((opts?.idleReclaimMs ?? 300_000) / 60_000));
|
|
20
24
|
const callTimeoutSeconds = Math.round((opts?.toolCallTimeoutMs ?? 270_000) / 1000);
|
|
25
|
+
const limits = scriptTimeLimits(opts);
|
|
21
26
|
return {
|
|
22
27
|
name: CODE_EXECUTION_TOOL_NAME,
|
|
23
28
|
description:
|
|
@@ -36,6 +41,9 @@ export function buildCodeExecutionToolDefinition(opts?: {
|
|
|
36
41
|
'what you need. Interpreter state (variables, imports) persists across code_execution ' +
|
|
37
42
|
`calls but is reclaimed after ~${idleMinutes} minutes idle. A tool call that receives no ` +
|
|
38
43
|
`response within ~${callTimeoutSeconds}s raises TimeoutError inside the script. ` +
|
|
44
|
+
`A foreground script is stopped after ${formatLimit(limits.defaultMs)}; pass time_limit_ms to set this call's limit ` +
|
|
45
|
+
`(at most ${formatLimit(limits.maxMs)}; a background script runs up to ${formatLimit(limits.backgroundMaxMs)}). ` +
|
|
46
|
+
'Stopping a script does not stop tools it already called. ' +
|
|
39
47
|
'Use this when fanning out across many items, looping over tool calls, or when tool ' +
|
|
40
48
|
'results are large and you only need a slice or summary. Call tools directly (not via ' +
|
|
41
49
|
'code) when a single call answers the question or when you need to reason about each ' +
|
|
@@ -72,8 +80,46 @@ export function buildCodeExecutionToolDefinition(opts?: {
|
|
|
72
80
|
type: 'string',
|
|
73
81
|
description: 'Background script id (for action: cancel).',
|
|
74
82
|
},
|
|
83
|
+
time_limit_ms: {
|
|
84
|
+
type: 'integer',
|
|
85
|
+
description:
|
|
86
|
+
`Time limit for this script in milliseconds; it is stopped when the limit is reached. ` +
|
|
87
|
+
`Default ${limits.defaultMs}, at most ${limits.maxMs}; a background script defaults to and is capped at ` +
|
|
88
|
+
`its ${formatLimit(limits.backgroundMaxMs)} lifetime.`,
|
|
89
|
+
},
|
|
75
90
|
},
|
|
76
91
|
required: [],
|
|
77
92
|
},
|
|
78
93
|
};
|
|
79
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A script's time limits: the default, the most an agent may ask for with
|
|
98
|
+
* `time_limit_ms`, and the background lifetime (default and ceiling at once).
|
|
99
|
+
*/
|
|
100
|
+
export function scriptTimeLimits(opts?: {
|
|
101
|
+
scriptTimeoutMs?: number;
|
|
102
|
+
maxScriptTimeoutMs?: number;
|
|
103
|
+
backgroundMaxLifetimeMs?: number;
|
|
104
|
+
}): { defaultMs: number; maxMs: number; backgroundMaxMs: number } {
|
|
105
|
+
// Every limit fits Node's timer range (MAX_TIMER_MS, ~24.8 days).
|
|
106
|
+
const defaultMs = Math.min(MAX_TIMER_MS, opts?.scriptTimeoutMs ?? 600_000);
|
|
107
|
+
return {
|
|
108
|
+
defaultMs,
|
|
109
|
+
// A ceiling below the default is refused when the framework is created
|
|
110
|
+
// (validateCodeExecutionConfig); never below the default here either.
|
|
111
|
+
maxMs: Math.min(MAX_TIMER_MS, Math.max(defaultMs, opts?.maxScriptTimeoutMs ?? defaultMs)),
|
|
112
|
+
backgroundMaxMs: Math.min(MAX_TIMER_MS, opts?.backgroundMaxLifetimeMs ?? 86_400_000),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Refuse a configuration whose time limits contradict each other. */
|
|
117
|
+
export function validateCodeExecutionConfig(cfg: { scriptTimeoutMs?: number; maxScriptTimeoutMs?: number }): void {
|
|
118
|
+
const defaultMs = cfg.scriptTimeoutMs ?? 600_000;
|
|
119
|
+
if (cfg.maxScriptTimeoutMs !== undefined && !(cfg.maxScriptTimeoutMs >= defaultMs)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`codeExecution.maxScriptTimeoutMs (${cfg.maxScriptTimeoutMs}) must be at least scriptTimeoutMs (${defaultMs}): ` +
|
|
122
|
+
'it is the most an agent may ask for, and the default is always allowed',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|