@graphorin/agent 0.6.0 → 0.7.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/CHANGELOG.md +96 -0
- package/README.md +31 -11
- package/dist/errors/index.d.ts +17 -4
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +19 -3
- package/dist/errors/index.js.map +1 -1
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +153 -1930
- package/dist/factory.js.map +1 -1
- package/dist/fanout/index.d.ts +13 -1
- package/dist/fanout/index.d.ts.map +1 -1
- package/dist/fanout/index.js +13 -4
- package/dist/fanout/index.js.map +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -9
- package/dist/index.js.map +1 -1
- package/dist/lateral-leak/index.js +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/run-state/index.d.ts +32 -6
- package/dist/run-state/index.d.ts.map +1 -1
- package/dist/run-state/index.js +46 -22
- package/dist/run-state/index.js.map +1 -1
- package/dist/runtime/agent-surface.js +122 -0
- package/dist/runtime/agent-surface.js.map +1 -0
- package/dist/runtime/agent-to-tool.d.ts +51 -0
- package/dist/runtime/agent-to-tool.d.ts.map +1 -0
- package/dist/runtime/agent-to-tool.js +145 -0
- package/dist/runtime/agent-to-tool.js.map +1 -0
- package/dist/runtime/approvals.js +0 -0
- package/dist/runtime/approvals.js.map +1 -0
- package/dist/runtime/dispatch.js +108 -0
- package/dist/runtime/dispatch.js.map +1 -0
- package/dist/runtime/executor-wiring.js +128 -0
- package/dist/runtime/executor-wiring.js.map +1 -0
- package/dist/runtime/fallback-chain.js +139 -0
- package/dist/runtime/fallback-chain.js.map +1 -0
- package/dist/runtime/handoff.js +307 -0
- package/dist/runtime/handoff.js.map +1 -0
- package/dist/runtime/messages.d.ts +22 -0
- package/dist/runtime/messages.d.ts.map +1 -0
- package/dist/runtime/messages.js +204 -0
- package/dist/runtime/messages.js.map +1 -0
- package/dist/runtime/provider-events.js +117 -0
- package/dist/runtime/provider-events.js.map +1 -0
- package/dist/runtime/run-compaction.js +210 -0
- package/dist/runtime/run-compaction.js.map +1 -0
- package/dist/runtime/run-finish.js +48 -0
- package/dist/runtime/run-finish.js.map +1 -0
- package/dist/runtime/run-gates.js +336 -0
- package/dist/runtime/run-gates.js.map +1 -0
- package/dist/runtime/run-init.js +81 -0
- package/dist/runtime/run-init.js.map +1 -0
- package/dist/runtime/run-input.js +46 -0
- package/dist/runtime/run-input.js.map +1 -0
- package/dist/runtime/step-catalogue.js +173 -0
- package/dist/runtime/step-catalogue.js.map +1 -0
- package/dist/runtime/tool-call-walk.js +189 -0
- package/dist/runtime/tool-call-walk.js.map +1 -0
- package/dist/runtime/tool-wiring.js +159 -0
- package/dist/runtime/tool-wiring.js.map +1 -0
- package/dist/tooling/adapters.js +1 -1
- package/dist/tooling/dataflow.js +1 -1
- package/dist/tooling/policy.js +2 -0
- package/dist/tooling/policy.js.map +1 -1
- package/dist/types.d.ts +63 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +20 -20
- package/src/errors/index.ts +320 -0
- package/src/evaluator-optimizer/index.ts +212 -0
- package/src/factory.ts +957 -0
- package/src/fallback/index.ts +108 -0
- package/src/fanout/index.ts +523 -0
- package/src/filters/index.ts +347 -0
- package/src/index.ts +180 -0
- package/src/internal/ids.ts +46 -0
- package/src/internal/usage-accumulator.ts +90 -0
- package/src/lateral-leak/causality-monitor.ts +221 -0
- package/src/lateral-leak/index.ts +35 -0
- package/src/lateral-leak/merge-guard.ts +151 -0
- package/src/lateral-leak/protocol-guard.ts +222 -0
- package/src/preferred-model/index.ts +210 -0
- package/src/progress/index.ts +238 -0
- package/src/run-state/index.ts +607 -0
- package/src/runtime/agent-surface.ts +218 -0
- package/src/runtime/agent-to-tool.ts +323 -0
- package/src/runtime/approvals.ts +0 -0
- package/src/runtime/dispatch.ts +183 -0
- package/src/runtime/executor-wiring.ts +331 -0
- package/src/runtime/fallback-chain.ts +250 -0
- package/src/runtime/handoff.ts +428 -0
- package/src/runtime/messages.ts +309 -0
- package/src/runtime/provider-events.ts +175 -0
- package/src/runtime/run-compaction.ts +288 -0
- package/src/runtime/run-finish.ts +93 -0
- package/src/runtime/run-gates.ts +419 -0
- package/src/runtime/run-init.ts +169 -0
- package/src/runtime/run-input.ts +102 -0
- package/src/runtime/step-catalogue.ts +338 -0
- package/src/runtime/tool-call-walk.ts +301 -0
- package/src/runtime/tool-wiring.ts +218 -0
- package/src/testing/replay-provider.ts +121 -0
- package/src/tooling/adapters.ts +403 -0
- package/src/tooling/catalogue.ts +36 -0
- package/src/tooling/dataflow.ts +171 -0
- package/src/tooling/plan.ts +123 -0
- package/src/tooling/policy.ts +67 -0
- package/src/tooling/registry-build.ts +191 -0
- package/src/types.ts +696 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-level model fallback chain primitives.
|
|
3
|
+
*
|
|
4
|
+
* The agent runtime walks `[primaryModel, ...Agent.fallbackModels]`
|
|
5
|
+
* on fallback-eligible errors during the per-step provider call.
|
|
6
|
+
* `isAgentFallbackEligible(...)` is the pure decision function the
|
|
7
|
+
* runtime calls once per `ProviderError`.
|
|
8
|
+
*
|
|
9
|
+
* Layering: this module is the **agent-step-level** fallback
|
|
10
|
+
* (re-tries the whole step against a different model on rate-limit
|
|
11
|
+
* / capacity / context-length / transient errors). The
|
|
12
|
+
* **request-level** `withFallback` provider middleware
|
|
13
|
+
* (`@graphorin/provider`) is a separate concern - it retries against
|
|
14
|
+
* an alternate provider serving the **same** model concept on
|
|
15
|
+
* transient errors inside one `provider.stream(...)` call.
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ProviderError } from '@graphorin/core';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Operator-supplied policy that lets the consumer toggle which
|
|
24
|
+
* `ProviderError` kinds the agent runtime should consider eligible
|
|
25
|
+
* for whole-step retries against the next model in the chain.
|
|
26
|
+
*
|
|
27
|
+
* @stable
|
|
28
|
+
*/
|
|
29
|
+
export interface AgentFallbackPolicy {
|
|
30
|
+
/** Default `true`. */
|
|
31
|
+
readonly rateLimitEligible?: boolean;
|
|
32
|
+
/** Default `true`. */
|
|
33
|
+
readonly capacityEligible?: boolean;
|
|
34
|
+
/** Default `true`. */
|
|
35
|
+
readonly contextLengthEligible?: boolean;
|
|
36
|
+
/** Default `false` - `withRetry` already covers transient errors. */
|
|
37
|
+
readonly transientEligible?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stable taxonomy returned by {@link isAgentFallbackEligible} on
|
|
42
|
+
* eligible errors.
|
|
43
|
+
*
|
|
44
|
+
* @stable
|
|
45
|
+
*/
|
|
46
|
+
export type AgentFallbackReason = 'rate-limit' | 'capacity' | 'context-length' | 'transient';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Outcome of {@link isAgentFallbackEligible}.
|
|
50
|
+
*
|
|
51
|
+
* @stable
|
|
52
|
+
*/
|
|
53
|
+
export interface AgentFallbackEligibility {
|
|
54
|
+
readonly eligible: boolean;
|
|
55
|
+
readonly reason?: AgentFallbackReason;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_POLICY: Required<AgentFallbackPolicy> = {
|
|
59
|
+
rateLimitEligible: true,
|
|
60
|
+
capacityEligible: true,
|
|
61
|
+
contextLengthEligible: true,
|
|
62
|
+
transientEligible: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pure dispatcher that maps a {@link ProviderError} to one of four
|
|
67
|
+
* eligible reasons or to `eligible: false` if the error is on the
|
|
68
|
+
* bypass list (`auth | invalid-input | content-filter | cancelled`).
|
|
69
|
+
*
|
|
70
|
+
* The function is intentionally allocation-free on the hot path so
|
|
71
|
+
* the agent runtime can call it once per provider error per step
|
|
72
|
+
* without budget concerns.
|
|
73
|
+
*
|
|
74
|
+
* @stable
|
|
75
|
+
*/
|
|
76
|
+
export function isAgentFallbackEligible(
|
|
77
|
+
error: ProviderError,
|
|
78
|
+
policy: AgentFallbackPolicy = {},
|
|
79
|
+
): AgentFallbackEligibility {
|
|
80
|
+
const merged: Required<AgentFallbackPolicy> = { ...DEFAULT_POLICY, ...policy };
|
|
81
|
+
switch (error.kind) {
|
|
82
|
+
case 'rate-limit':
|
|
83
|
+
return merged.rateLimitEligible
|
|
84
|
+
? { eligible: true, reason: 'rate-limit' }
|
|
85
|
+
: { eligible: false };
|
|
86
|
+
case 'capacity':
|
|
87
|
+
return merged.capacityEligible ? { eligible: true, reason: 'capacity' } : { eligible: false };
|
|
88
|
+
case 'context-length':
|
|
89
|
+
return merged.contextLengthEligible
|
|
90
|
+
? { eligible: true, reason: 'context-length' }
|
|
91
|
+
: { eligible: false };
|
|
92
|
+
case 'transient':
|
|
93
|
+
return merged.transientEligible
|
|
94
|
+
? { eligible: true, reason: 'transient' }
|
|
95
|
+
: { eligible: false };
|
|
96
|
+
case 'invalid-request':
|
|
97
|
+
case 'unauthorized':
|
|
98
|
+
case 'content-filter':
|
|
99
|
+
case 'unknown':
|
|
100
|
+
return { eligible: false };
|
|
101
|
+
default: {
|
|
102
|
+
// Defensive: unknown error kind treated as ineligible.
|
|
103
|
+
const _exhaustive: never = error.kind;
|
|
104
|
+
void _exhaustive;
|
|
105
|
+
return { eligible: false };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-step-level fan-out - `Agent.fanOut(...)` convenience that
|
|
3
|
+
* spawns N sub-agents in parallel under a bounded-fanout cap with
|
|
4
|
+
* per-child budgets and four built-in merge strategies.
|
|
5
|
+
*
|
|
6
|
+
* Boundary discipline against the workflow `Dispatch(...)`
|
|
7
|
+
* primitive: fan-out is **agent-step-level inline result** (children
|
|
8
|
+
* share parent `RunContext` lineage; result consumed by parent's
|
|
9
|
+
* continuing loop within one or few `agent.run(...)` calls).
|
|
10
|
+
* `Dispatch(...)` is workflow-step-level checkpointed durable graph.
|
|
11
|
+
* The two compose orthogonally.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { type AgentEvent, type FanOutChildMetadata, type Usage, zeroUsage } from '@graphorin/core';
|
|
18
|
+
import { MergeBlockedError } from '../errors/index.js';
|
|
19
|
+
import {
|
|
20
|
+
type ContentOriginKind,
|
|
21
|
+
computeSourceTrust,
|
|
22
|
+
evaluateMerge,
|
|
23
|
+
type MergeGuardConfig,
|
|
24
|
+
type TrustClass,
|
|
25
|
+
} from '../lateral-leak/merge-guard.js';
|
|
26
|
+
import { accumulateUsage } from '../runtime/messages.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Per-child budget. Defaults derived from the canonical 2026
|
|
30
|
+
* scaling-rule table for agent fan-out workloads.
|
|
31
|
+
*
|
|
32
|
+
* @stable
|
|
33
|
+
*/
|
|
34
|
+
export interface PerChildBudget {
|
|
35
|
+
/**
|
|
36
|
+
* Max `usage.totalTokens` per child. Enforced **post-hoc** and only
|
|
37
|
+
* for usage-reporting children (an `invoke` that resolves to a full
|
|
38
|
+
* `AgentResult` - e.g. `() => child.run(input)`); a child returning a
|
|
39
|
+
* plain value reports `tokensUsed: 0` and this cap cannot fire.
|
|
40
|
+
*/
|
|
41
|
+
readonly tokens?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Max tool calls per child. Same usage-reporting contract as
|
|
44
|
+
* {@link PerChildBudget.tokens} (counted from `state.steps`).
|
|
45
|
+
*/
|
|
46
|
+
readonly toolCalls?: number;
|
|
47
|
+
/** Wall-clock cap, enforced for every child via a race timer. */
|
|
48
|
+
readonly durationMs?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Built-in merge-strategy taxonomy.
|
|
53
|
+
*
|
|
54
|
+
* @stable
|
|
55
|
+
*/
|
|
56
|
+
export type MergeStrategy<TOutput = unknown> =
|
|
57
|
+
| { readonly kind: 'concat'; readonly separator?: string }
|
|
58
|
+
| { readonly kind: 'first-success' }
|
|
59
|
+
| {
|
|
60
|
+
readonly kind: 'judge-merge';
|
|
61
|
+
readonly judge: (children: ReadonlyArray<ChildResult<TOutput>>) => Promise<TOutput>;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
readonly kind: 'custom';
|
|
65
|
+
readonly merge: (children: ReadonlyArray<ChildResult<TOutput>>) => Promise<TOutput>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-child outcome surfaced on
|
|
70
|
+
* {@link FanOutResult.children}. Failed-child isolation: a child
|
|
71
|
+
* that throws produces a `ChildResult` with `status: 'failed'` -
|
|
72
|
+
* never an exception thrown from the fan-out call itself.
|
|
73
|
+
*
|
|
74
|
+
* @stable
|
|
75
|
+
*/
|
|
76
|
+
export interface ChildResult<TOutput = unknown> {
|
|
77
|
+
readonly agentId: string;
|
|
78
|
+
readonly status: 'completed' | 'failed' | 'budget-exceeded' | 'cancelled';
|
|
79
|
+
readonly output?: TOutput;
|
|
80
|
+
readonly error?: { readonly message: string; readonly code: string };
|
|
81
|
+
readonly tokensUsed: number;
|
|
82
|
+
readonly toolCallCount: number;
|
|
83
|
+
readonly durationMs: number;
|
|
84
|
+
/**
|
|
85
|
+
* Full usage breakdown, present only for usage-reporting children
|
|
86
|
+
* (an `invoke` resolving to a full `AgentResult`, W-033).
|
|
87
|
+
*/
|
|
88
|
+
readonly usage?: Usage;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Aggregate result returned by `Agent.fanOut(...)`.
|
|
93
|
+
*
|
|
94
|
+
* @stable
|
|
95
|
+
*/
|
|
96
|
+
export interface FanOutResult<TOutput = unknown> {
|
|
97
|
+
readonly fanOutId: string;
|
|
98
|
+
readonly output: TOutput;
|
|
99
|
+
readonly children: ReadonlyArray<ChildResult<TOutput>>;
|
|
100
|
+
readonly mergeDurationMs: number;
|
|
101
|
+
/**
|
|
102
|
+
* Sum of every usage-reporting child's usage (W-033); zero when no
|
|
103
|
+
* child reported. The fan-out helper never mutates the parent run's
|
|
104
|
+
* live state (it runs outside the loop and would race it) - folding
|
|
105
|
+
* this into the parent run's accounting is the caller's decision.
|
|
106
|
+
*/
|
|
107
|
+
readonly usage: Usage;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Per-call options accepted by `Agent.fanOut(...)`.
|
|
112
|
+
*
|
|
113
|
+
* @stable
|
|
114
|
+
*/
|
|
115
|
+
export interface FanOutOptions<TOutput = unknown> {
|
|
116
|
+
/**
|
|
117
|
+
* The sub-agents to invoke. Each entry is invoked as a function
|
|
118
|
+
* returning a `Promise<TOutput>` - the fan-out helper does not
|
|
119
|
+
* impose an `Agent` shape on the children so the runtime can
|
|
120
|
+
* adapt any callable surface.
|
|
121
|
+
*/
|
|
122
|
+
readonly children: ReadonlyArray<{
|
|
123
|
+
readonly agentId: string;
|
|
124
|
+
/**
|
|
125
|
+
* Child callable. Resolve to a plain `TOutput`, or to a full
|
|
126
|
+
* `AgentResult` (e.g. `() => childAgent.run(input)`) - the fan-out
|
|
127
|
+
* detects the result envelope structurally (`output` + numeric
|
|
128
|
+
* `usage.totalTokens` + `state`), unwraps `output`, and harvests
|
|
129
|
+
* `tokensUsed` / `toolCallCount` so per-child budgets can enforce.
|
|
130
|
+
*/
|
|
131
|
+
readonly invoke: () => Promise<TOutput>;
|
|
132
|
+
/** Trust-class for the merge guard (default `'loopback'`). */
|
|
133
|
+
readonly trustClass?: TrustClass;
|
|
134
|
+
/** Content-origin for the merge guard (default `'built-in'`). */
|
|
135
|
+
readonly origin?: ContentOriginKind;
|
|
136
|
+
/** Rolling trust adjustment in `[0,1]` (default `1`). */
|
|
137
|
+
readonly historyAdjustment?: number;
|
|
138
|
+
}>;
|
|
139
|
+
/** Default `4` per the canonical 2026 production lesson. */
|
|
140
|
+
readonly maxConcurrentChildren?: number;
|
|
141
|
+
/** Per-child budget; default unset. */
|
|
142
|
+
readonly perBudget?: PerChildBudget;
|
|
143
|
+
/** Default `{ kind: 'concat' }`. */
|
|
144
|
+
readonly mergeStrategy?: MergeStrategy<TOutput>;
|
|
145
|
+
readonly signal?: AbortSignal;
|
|
146
|
+
/** Optional callback for per-child completion observability. */
|
|
147
|
+
readonly onChildResult?: (result: ChildResult<TOutput>) => void;
|
|
148
|
+
/** Optional event emitter for `agent.fanout.spawned / merged`. */
|
|
149
|
+
readonly emit?: (event: AgentEvent) => void;
|
|
150
|
+
/**
|
|
151
|
+
* Sideways-injection merge guard (AG-7): on `'judge-merge'` the
|
|
152
|
+
* fan-out scores each child's source trust and contribution weight
|
|
153
|
+
* against the judge's merged output; a biased merge emits
|
|
154
|
+
* `agent.lateral-leak.detected` (vector `sideways-injection`) and -
|
|
155
|
+
* under `strictness: 'detect-and-block'` - throws
|
|
156
|
+
* {@link MergeBlockedError}.
|
|
157
|
+
*/
|
|
158
|
+
readonly mergeGuard?: MergeGuardConfig;
|
|
159
|
+
/** Identifiers required to populate the events. */
|
|
160
|
+
readonly runId: string;
|
|
161
|
+
readonly sessionId: string;
|
|
162
|
+
readonly agentId: string;
|
|
163
|
+
/** Default - generated from `runId + Date.now()`. */
|
|
164
|
+
readonly fanOutId?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const DEFAULT_MAX_CONCURRENT_CHILDREN = 4;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Structurally detect a full `AgentResult` returned by a child
|
|
171
|
+
* `invoke` (AG-16): an object carrying `output`, a numeric
|
|
172
|
+
* `usage.totalTokens`, and a `state` with string `id`/`status`. The
|
|
173
|
+
* three-field match makes accidental collision with a user `TOutput`
|
|
174
|
+
* negligible; children whose genuine output looks like this should
|
|
175
|
+
* resolve a plain value instead.
|
|
176
|
+
*/
|
|
177
|
+
function harvestAgentResult(value: unknown):
|
|
178
|
+
| {
|
|
179
|
+
readonly output: unknown;
|
|
180
|
+
readonly tokensUsed: number;
|
|
181
|
+
readonly toolCallCount: number;
|
|
182
|
+
readonly usage?: Usage;
|
|
183
|
+
}
|
|
184
|
+
| undefined {
|
|
185
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
186
|
+
const v = value as {
|
|
187
|
+
readonly output?: unknown;
|
|
188
|
+
readonly usage?: {
|
|
189
|
+
readonly totalTokens?: unknown;
|
|
190
|
+
readonly promptTokens?: unknown;
|
|
191
|
+
readonly completionTokens?: unknown;
|
|
192
|
+
};
|
|
193
|
+
readonly state?: { readonly id?: unknown; readonly status?: unknown; readonly steps?: unknown };
|
|
194
|
+
};
|
|
195
|
+
if (!('output' in v)) return undefined;
|
|
196
|
+
if (typeof v.usage?.totalTokens !== 'number') return undefined;
|
|
197
|
+
if (typeof v.state?.id !== 'string' || typeof v.state.status !== 'string') return undefined;
|
|
198
|
+
let toolCallCount = 0;
|
|
199
|
+
if (Array.isArray(v.state.steps)) {
|
|
200
|
+
for (const step of v.state.steps) {
|
|
201
|
+
const calls = (step as { readonly toolCalls?: unknown }).toolCalls;
|
|
202
|
+
if (Array.isArray(calls)) toolCallCount += calls.length;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const usage =
|
|
206
|
+
typeof v.usage.promptTokens === 'number' && typeof v.usage.completionTokens === 'number'
|
|
207
|
+
? (v.usage as Usage)
|
|
208
|
+
: undefined;
|
|
209
|
+
return {
|
|
210
|
+
output: v.output,
|
|
211
|
+
tokensUsed: v.usage.totalTokens,
|
|
212
|
+
toolCallCount,
|
|
213
|
+
...(usage !== undefined ? { usage } : {}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whitespace-token overlap of a child's output against the merged
|
|
219
|
+
* output, in `[0,1]` - the contribution-weight estimate the merge
|
|
220
|
+
* guard's docstring contracts (each child scored independently; a
|
|
221
|
+
* judge parroting one child verbatim yields ~1.0 for that child).
|
|
222
|
+
*/
|
|
223
|
+
function contributionWeight(childText: string, mergedText: string): number {
|
|
224
|
+
if (childText.length === 0 || mergedText.length === 0) return 0;
|
|
225
|
+
const mergedTokens = mergedText.split(/\s+/).filter((t) => t.length > 0);
|
|
226
|
+
if (mergedTokens.length === 0) return 0;
|
|
227
|
+
const childTokens = new Set(childText.split(/\s+/).filter((t) => t.length > 0));
|
|
228
|
+
let hits = 0;
|
|
229
|
+
for (const token of mergedTokens) {
|
|
230
|
+
if (childTokens.has(token)) hits += 1;
|
|
231
|
+
}
|
|
232
|
+
return hits / mergedTokens.length;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function runWithSemaphore<TOutput>(
|
|
236
|
+
children: ReadonlyArray<FanOutOptions<TOutput>['children'][number]>,
|
|
237
|
+
cap: number,
|
|
238
|
+
signal: AbortSignal | undefined,
|
|
239
|
+
perBudget: PerChildBudget | undefined,
|
|
240
|
+
onChildResult?: (r: ChildResult<TOutput>) => void,
|
|
241
|
+
): Promise<ReadonlyArray<ChildResult<TOutput>>> {
|
|
242
|
+
const results: ChildResult<TOutput>[] = new Array(children.length);
|
|
243
|
+
let cursor = 0;
|
|
244
|
+
const running: Set<Promise<void>> = new Set();
|
|
245
|
+
const limit = Math.max(1, Math.min(cap, children.length));
|
|
246
|
+
|
|
247
|
+
const launchOne = (index: number): Promise<void> => {
|
|
248
|
+
const child = children[index];
|
|
249
|
+
if (child === undefined) return Promise.resolve();
|
|
250
|
+
const start = Date.now();
|
|
251
|
+
const exec = async (): Promise<ChildResult<TOutput>> => {
|
|
252
|
+
if (signal?.aborted) {
|
|
253
|
+
return {
|
|
254
|
+
agentId: child.agentId,
|
|
255
|
+
status: 'cancelled',
|
|
256
|
+
tokensUsed: 0,
|
|
257
|
+
toolCallCount: 0,
|
|
258
|
+
durationMs: 0,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
262
|
+
try {
|
|
263
|
+
const timedPromise =
|
|
264
|
+
perBudget?.durationMs !== undefined
|
|
265
|
+
? new Promise<TOutput>((_, reject) => {
|
|
266
|
+
timer = setTimeout(
|
|
267
|
+
() => reject(new Error('budget-exceeded:durationMs')),
|
|
268
|
+
perBudget.durationMs,
|
|
269
|
+
);
|
|
270
|
+
})
|
|
271
|
+
: undefined;
|
|
272
|
+
const racePromise = timedPromise
|
|
273
|
+
? Promise.race([child.invoke(), timedPromise])
|
|
274
|
+
: child.invoke();
|
|
275
|
+
const raw = await racePromise;
|
|
276
|
+
// AG-16: a child resolving to a full AgentResult reports its
|
|
277
|
+
// real usage - unwrap the output and harvest the counters.
|
|
278
|
+
const report = harvestAgentResult(raw);
|
|
279
|
+
const output = (report === undefined ? raw : report.output) as TOutput;
|
|
280
|
+
const tokensUsed = report?.tokensUsed ?? 0;
|
|
281
|
+
const toolCallCount = report?.toolCallCount ?? 0;
|
|
282
|
+
// Post-hoc budget enforcement (only fires for usage-reporting
|
|
283
|
+
// children): the over-budget output is withheld from the merge.
|
|
284
|
+
const exceeded =
|
|
285
|
+
(perBudget?.tokens !== undefined && tokensUsed > perBudget.tokens
|
|
286
|
+
? `tokens ${tokensUsed} > ${perBudget.tokens}`
|
|
287
|
+
: undefined) ??
|
|
288
|
+
(perBudget?.toolCalls !== undefined && toolCallCount > perBudget.toolCalls
|
|
289
|
+
? `toolCalls ${toolCallCount} > ${perBudget.toolCalls}`
|
|
290
|
+
: undefined);
|
|
291
|
+
if (exceeded !== undefined) {
|
|
292
|
+
return {
|
|
293
|
+
agentId: child.agentId,
|
|
294
|
+
status: 'budget-exceeded',
|
|
295
|
+
error: { message: `budget-exceeded: ${exceeded}`, code: 'budget-exceeded' },
|
|
296
|
+
tokensUsed,
|
|
297
|
+
toolCallCount,
|
|
298
|
+
durationMs: Date.now() - start,
|
|
299
|
+
...(report?.usage !== undefined ? { usage: report.usage } : {}),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
agentId: child.agentId,
|
|
304
|
+
status: 'completed',
|
|
305
|
+
output,
|
|
306
|
+
tokensUsed,
|
|
307
|
+
toolCallCount,
|
|
308
|
+
durationMs: Date.now() - start,
|
|
309
|
+
...(report?.usage !== undefined ? { usage: report.usage } : {}),
|
|
310
|
+
};
|
|
311
|
+
} catch (cause) {
|
|
312
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
313
|
+
const aborted = signal?.aborted;
|
|
314
|
+
const status: ChildResult<TOutput>['status'] = message.startsWith('budget-exceeded')
|
|
315
|
+
? 'budget-exceeded'
|
|
316
|
+
: aborted
|
|
317
|
+
? 'cancelled'
|
|
318
|
+
: 'failed';
|
|
319
|
+
return {
|
|
320
|
+
agentId: child.agentId,
|
|
321
|
+
status,
|
|
322
|
+
error: { message, code: status },
|
|
323
|
+
tokensUsed: 0,
|
|
324
|
+
toolCallCount: 0,
|
|
325
|
+
durationMs: Date.now() - start,
|
|
326
|
+
};
|
|
327
|
+
} finally {
|
|
328
|
+
// AG-16: the duration timer must die on EVERY path - leaving it
|
|
329
|
+
// armed on rejection held the event loop open.
|
|
330
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const promise = exec().then((r) => {
|
|
334
|
+
results[index] = r;
|
|
335
|
+
onChildResult?.(r);
|
|
336
|
+
});
|
|
337
|
+
running.add(promise);
|
|
338
|
+
promise.finally(() => running.delete(promise));
|
|
339
|
+
return promise;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
while (cursor < children.length || running.size > 0) {
|
|
343
|
+
while (running.size < limit && cursor < children.length) {
|
|
344
|
+
void launchOne(cursor);
|
|
345
|
+
cursor += 1;
|
|
346
|
+
}
|
|
347
|
+
if (running.size > 0) {
|
|
348
|
+
await Promise.race(running);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return results;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Run a fan-out and produce the aggregate {@link FanOutResult}.
|
|
356
|
+
* Pure with respect to side effects - the runtime emits events /
|
|
357
|
+
* audit rows / counter increments via the supplied `emit` callback.
|
|
358
|
+
*
|
|
359
|
+
* @stable
|
|
360
|
+
*/
|
|
361
|
+
export async function runFanOut<TOutput>(
|
|
362
|
+
opts: FanOutOptions<TOutput>,
|
|
363
|
+
): Promise<FanOutResult<TOutput>> {
|
|
364
|
+
const fanOutId = opts.fanOutId ?? `fanout-${opts.runId.slice(-8)}-${Date.now().toString(36)}`;
|
|
365
|
+
const merge: MergeStrategy<TOutput> = opts.mergeStrategy ?? {
|
|
366
|
+
kind: 'concat',
|
|
367
|
+
};
|
|
368
|
+
const cap = opts.maxConcurrentChildren ?? DEFAULT_MAX_CONCURRENT_CHILDREN;
|
|
369
|
+
|
|
370
|
+
opts.emit?.({
|
|
371
|
+
type: 'agent.fanout.spawned',
|
|
372
|
+
runId: opts.runId,
|
|
373
|
+
sessionId: opts.sessionId,
|
|
374
|
+
agentId: opts.agentId,
|
|
375
|
+
fanOutId,
|
|
376
|
+
childCount: opts.children.length,
|
|
377
|
+
mergeStrategyKind: merge.kind,
|
|
378
|
+
spawnedAtIso: new Date().toISOString(),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const results = await runWithSemaphore<TOutput>(
|
|
382
|
+
opts.children,
|
|
383
|
+
cap,
|
|
384
|
+
opts.signal,
|
|
385
|
+
opts.perBudget,
|
|
386
|
+
opts.onChildResult,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const mergeStart = Date.now();
|
|
390
|
+
let merged: TOutput;
|
|
391
|
+
switch (merge.kind) {
|
|
392
|
+
case 'concat': {
|
|
393
|
+
const sep = merge.separator ?? '\n\n---\n\n';
|
|
394
|
+
const parts: string[] = [];
|
|
395
|
+
for (const r of results) {
|
|
396
|
+
if (r.status === 'completed' && r.output !== undefined) {
|
|
397
|
+
parts.push(typeof r.output === 'string' ? r.output : JSON.stringify(r.output));
|
|
398
|
+
} else if (
|
|
399
|
+
r.status === 'failed' ||
|
|
400
|
+
r.status === 'budget-exceeded' ||
|
|
401
|
+
r.status === 'cancelled'
|
|
402
|
+
) {
|
|
403
|
+
parts.push(`[${r.status}: ${r.agentId}]`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
merged = parts.join(sep) as unknown as TOutput;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case 'first-success': {
|
|
410
|
+
const first = results.find((r) => r.status === 'completed');
|
|
411
|
+
merged = first?.output ?? ('' as unknown as TOutput);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
case 'judge-merge': {
|
|
415
|
+
merged = await merge.judge(results);
|
|
416
|
+
// AG-7: the sideways-injection merge guard scores each child's
|
|
417
|
+
// source trust × contribution weight against the judge's merged
|
|
418
|
+
// output. A biased merge emits `agent.lateral-leak.detected`;
|
|
419
|
+
// 'detect-and-block' refuses the merge entirely.
|
|
420
|
+
if (opts.mergeGuard !== undefined && opts.mergeGuard.strictness !== 'off') {
|
|
421
|
+
const mergedText = typeof merged === 'string' ? merged : JSON.stringify(merged);
|
|
422
|
+
const overrides = opts.mergeGuard.sourceTrustOverrides ?? {};
|
|
423
|
+
const perChild = results.map((r, i) => {
|
|
424
|
+
const c = opts.children[i];
|
|
425
|
+
const childText =
|
|
426
|
+
r.output === undefined
|
|
427
|
+
? ''
|
|
428
|
+
: typeof r.output === 'string'
|
|
429
|
+
? r.output
|
|
430
|
+
: JSON.stringify(r.output);
|
|
431
|
+
return {
|
|
432
|
+
agentId: r.agentId,
|
|
433
|
+
sourceTrust: computeSourceTrust(
|
|
434
|
+
{
|
|
435
|
+
agentId: r.agentId,
|
|
436
|
+
trustClass: c?.trustClass ?? 'loopback',
|
|
437
|
+
origin: c?.origin ?? 'built-in',
|
|
438
|
+
...(c?.historyAdjustment !== undefined
|
|
439
|
+
? { historyAdjustment: c.historyAdjustment }
|
|
440
|
+
: {}),
|
|
441
|
+
},
|
|
442
|
+
overrides,
|
|
443
|
+
),
|
|
444
|
+
contributionWeight: contributionWeight(childText, mergedText),
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
const verdict = evaluateMerge(perChild, opts.mergeGuard);
|
|
448
|
+
if (verdict.biased) {
|
|
449
|
+
opts.emit?.({
|
|
450
|
+
type: 'agent.lateral-leak.detected',
|
|
451
|
+
runId: opts.runId,
|
|
452
|
+
sessionId: opts.sessionId,
|
|
453
|
+
agentId: opts.agentId,
|
|
454
|
+
vector: 'sideways-injection',
|
|
455
|
+
severity: verdict.decision === 'block' ? 'block' : 'warn',
|
|
456
|
+
causalityChain: verdict.offendingChild === undefined ? [] : [verdict.offendingChild],
|
|
457
|
+
messageContentSha256: createHash('sha256').update(mergedText, 'utf8').digest('hex'),
|
|
458
|
+
decision:
|
|
459
|
+
verdict.decision === 'block'
|
|
460
|
+
? 'block'
|
|
461
|
+
: verdict.decision === 'flag'
|
|
462
|
+
? 'flag'
|
|
463
|
+
: 'detect',
|
|
464
|
+
detectedAtIso: new Date().toISOString(),
|
|
465
|
+
});
|
|
466
|
+
if (verdict.decision === 'block') {
|
|
467
|
+
throw new MergeBlockedError(
|
|
468
|
+
fanOutId,
|
|
469
|
+
`low-trust child '${verdict.offendingChild}' (sourceTrust ${verdict.sourceTrust?.toFixed(2)}) contributes ${(
|
|
470
|
+
(verdict.contributionWeight ?? 0) * 100
|
|
471
|
+
).toFixed(0)}% of the merged output`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case 'custom':
|
|
479
|
+
merged = await merge.merge(results);
|
|
480
|
+
break;
|
|
481
|
+
default: {
|
|
482
|
+
const _exhaustive: never = merge;
|
|
483
|
+
void _exhaustive;
|
|
484
|
+
merged = '' as unknown as TOutput;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const mergeDurationMs = Date.now() - mergeStart;
|
|
488
|
+
|
|
489
|
+
const childMetadata: FanOutChildMetadata[] = results.map((r) => ({
|
|
490
|
+
agentId: r.agentId,
|
|
491
|
+
status: r.status,
|
|
492
|
+
tokensUsed: r.tokensUsed,
|
|
493
|
+
toolCallCount: r.toolCallCount,
|
|
494
|
+
durationMs: r.durationMs,
|
|
495
|
+
}));
|
|
496
|
+
const successfulChildCount = results.filter((r) => r.status === 'completed').length;
|
|
497
|
+
|
|
498
|
+
opts.emit?.({
|
|
499
|
+
type: 'agent.fanout.merged',
|
|
500
|
+
runId: opts.runId,
|
|
501
|
+
sessionId: opts.sessionId,
|
|
502
|
+
agentId: opts.agentId,
|
|
503
|
+
fanOutId,
|
|
504
|
+
childCount: opts.children.length,
|
|
505
|
+
successfulChildCount,
|
|
506
|
+
mergeStrategyKind: merge.kind,
|
|
507
|
+
mergeDurationMs,
|
|
508
|
+
childMetadata,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const usage = zeroUsage();
|
|
512
|
+
for (const r of results) {
|
|
513
|
+
if (r.usage !== undefined) accumulateUsage(usage, r.usage);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
fanOutId,
|
|
518
|
+
output: merged,
|
|
519
|
+
children: results,
|
|
520
|
+
mergeDurationMs,
|
|
521
|
+
usage,
|
|
522
|
+
};
|
|
523
|
+
}
|