Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,15 @@ export type {
GenericToolLedgerAppendValidation,
ToolLedgerLane,
ToolLedgerLaneValidation,
ToolLedgerRejectionCode,
ToolLedgerScanOperation,
ToolLedgerScanResult,
ToolLedgerTransitionKind,
ToolLedgerTransitionValidation,
} from './tool-ledger-scanner.js';
export {
ToolLedgerCorruptionError,
ToolLedgerRejectionError,
scanToolLedger,
validateGenericToolLedgerAppend,
validateToolLedgerEventLane,
Expand Down
58 changes: 52 additions & 6 deletions packages/core/src/tool-ledger-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,49 @@ export interface ToolLedgerIssue {
toolCallId?: string;
}

/**
* The ledger refused a CANDIDATE event because that event was wrong — the store
* itself is healthy and still readable. The distinction decides what a run may
* do next: when the ledger can no longer be trusted the run latches its store
* unavailable and fails closed, but latching it for a bad candidate costs the
* run its own terminal write, which is how one refused append left a run stuck
* at `running` with no terminal event at all (#2234). This error is always a
* producer bug: something emitted a fact the ledger's invariants forbid.
*
* It deliberately does NOT cover a ledger that is already corrupt. That refusal
* rejects well-formed candidates because of damage elsewhere in the workspace,
* so "the store is healthy" is false and the run must keep failing closed —
* see `ToolLedgerCorruptionError`, which is a plain durability failure and is
* classified as one.
*/
export class ToolLedgerRejectionError extends Error {
readonly name = 'ToolLedgerRejectionError';

constructor(
readonly code: ToolLedgerRejectionCode,
readonly eventId: string,
) {
super(`Tool ledger transition rejected: ${code} at ${eventId}`);
}
}

/**
* The ledger the store already holds is corrupt, so it refuses writes that have
* nothing wrong with them. Unlike `ToolLedgerRejectionError` this is not a
* producer bug and the store is not usable: nothing the run emits next can be
* trusted to land, so it stays on the fail-closed path.
*/
export class ToolLedgerCorruptionError extends Error {
readonly name = 'ToolLedgerCorruptionError';

constructor(
readonly code: ToolLedgerRejectionCode,
readonly eventId: string,
) {
super(`Tool ledger is corrupt: ${code} at ${eventId}`);
}
}

export interface ToolLedgerScanOperation {
toolCallId: string;
toolName?: string;
Expand Down Expand Up @@ -67,16 +110,19 @@ export type ToolLedgerTransitionKind =
| 't2_outcome'
| 'recovery_bundle';

/** Every reason the ledger has for refusing a candidate event. */
export type ToolLedgerRejectionCode =
| ToolLedgerIssueCode
| 'semantic_lane_conflict'
| 'reserved_tool_boundary_fact'
| 'reserved_recovery_fact'
| 'transition_shape_conflict';

export type ToolLedgerTransitionValidation =
| { ok: true }
| {
ok: false;
code:
| ToolLedgerIssueCode
| 'semantic_lane_conflict'
| 'reserved_tool_boundary_fact'
| 'reserved_recovery_fact'
| 'transition_shape_conflict';
code: ToolLedgerRejectionCode;
eventId: string;
operationId?: string;
toolCallId?: string;
Expand Down
40 changes: 37 additions & 3 deletions packages/runtime/src/__tests__/agent-swarm-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,44 @@ describe('AgentSwarm adapter', () => {
);

assert.equal(starts, 0);
assert.match(
String((result as { error?: unknown }).error),
/Provide exactly one of subagent_id or legacy profile/,
const refusal = String((result as { error?: unknown }).error);
assert.match(refusal, /Neither subagent_id nor profile is set, so no child is selected/);
// The refusal has to carry both ways out, or a model can only guess: the
// preset ids are behind agent_list and the legacy profiles are a closed set.
assert.match(refusal, /a user-approved preset id from agent_list/);
assert.match(refusal, /profile to one of: [^.]*\blocal_read\b/);
});

test('rejects an item that sets both selectors, and says which mistake it is', async () => {
let starts = 0;
const runtime = buildRuntime(async () => {
starts += 1;
return childResult(starts);
});

const result = await executeTool(
runtime,
buildAgentSwarmTool(),
{
items: [
{
item_id: 'both-selectors',
task: 'Inspect runtime validation.',
profile: LOCAL_READ_AGENT_PROFILE,
subagent_id: 'reviewer',
},
],
},
new AbortController(),
);

assert.equal(starts, 0);
const refusal = String((result as { error?: unknown }).error);
// The opposite mistake must not read as the same sentence — a model told
// "neither is set" while it set both learns nothing it can act on.
assert.match(refusal, /subagent_id and profile are both set/);
assert.doesNotMatch(refusal, /Neither subagent_id nor profile is set/);
assert.match(refusal, /a user-approved preset id from agent_list/);
});

test('accepts prompt_template with string items and rejects ambiguous template input', () => {
Expand Down
Loading
Loading