Skip to content

Commit 6dc44a8

Browse files
grypezclaude
andcommitted
fix(kernel-agents): report invalid-argument tool calls as tool errors
Normalize capability invocation failures at the exo membrane: capabilitiesFrom wraps every guarded call so a guard rejection (or any thrown value) becomes a real Error naming the expected signature, e.g. "Error calling add(a: number, b: number): …". This holds even when the guard rejects with an opaque value (as under the test shim), so every caller — both agent strategies and remote discover() — gets an actionable message instead of a load-bearing String(error) fallback at one call site. The chat agent surfaces that message verbatim and only frames JSON parse failures itself. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1ab9888 commit 6dc44a8

4 files changed

Lines changed: 127 additions & 31 deletions

File tree

packages/kernel-agents/src/capabilities/discover.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ describe('makeInternalCapabilities', () => {
6969
expect(await rejects(capabilities.add.func({} as never))).toBe(true);
7070
});
7171

72+
it('normalizes the rejection into a real error naming the expected signature', async () => {
73+
// Whatever the guard rejects with (an opaque value under the test shim), the
74+
// membrane rethrows a real `Error` carrying the method signature so callers
75+
// can surface an actionable message to the model.
76+
let caught: unknown;
77+
try {
78+
await capabilities.count.func({ word: 12345 } as never);
79+
} catch (error) {
80+
caught = error;
81+
}
82+
expect(caught).toBeInstanceOf(Error);
83+
expect(
84+
(caught as Error).message.startsWith(
85+
'Error calling count(word: string): ',
86+
),
87+
).toBe(true);
88+
});
89+
7290
it('throws at construction when an implementation has no matching schema', () => {
7391
expect(() =>
7492
makeInternalCapabilities(

packages/kernel-agents/src/capabilities/discover.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ import type { CapabilityRecord, CapabilitySpec } from '../types.ts';
1616
*/
1717
type Invoke = (method: string, positionalArgs: unknown[]) => unknown;
1818

19+
/**
20+
* Render a method's expected call signature from its schema — e.g.
21+
* `add(a: number, b: number)` — for use in invocation-error messages.
22+
*
23+
* @param name - The method name.
24+
* @param schema - The method schema whose `args` describe the parameters.
25+
* @returns The formatted signature.
26+
*/
27+
const formatSignature = (name: string, schema: MethodSchema): string => {
28+
const params = Object.entries(schema.args)
29+
.map(([arg, argSchema]) => `${arg}: ${argSchema.type}`)
30+
.join(', ');
31+
return `${name}(${params})`;
32+
};
33+
1934
/**
2035
* Build a {@link CapabilityRecord} from a method-schema description, mapping each
2136
* capability's object arguments to positional arguments for the exo method.
@@ -36,12 +51,26 @@ const capabilitiesFrom = (
3651
Object.fromEntries(
3752
Object.entries(description).map(([name, schema]) => {
3853
const argNames = Object.keys(schema.args);
39-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
40-
const func = async (args: Record<string, unknown>) =>
41-
invoke(
42-
name,
43-
argNames.map((argName) => args[argName]),
44-
);
54+
const func = async (args: Record<string, unknown>): Promise<unknown> => {
55+
try {
56+
return await invoke(
57+
name,
58+
argNames.map((argName) => args[argName]),
59+
);
60+
} catch (error) {
61+
// The exo's interface guard is the sole argument enforcer, so a shape
62+
// mismatch rejects here before the implementation runs — but that is
63+
// indistinguishable from an error thrown by the implementation, so
64+
// the signature is reported as context, not a diagnosed cause.
65+
// Wrapping also guarantees a real `Error` even when the guard rejects
66+
// with an opaque value (e.g. under the test shim), so every caller
67+
// gets the method signature to surface to the model.
68+
const detail = error instanceof Error ? error.message : String(error);
69+
throw new Error(
70+
`Error calling ${formatSignature(name, schema)}: ${detail}`,
71+
);
72+
}
73+
};
4574
return [name, { func, schema }] as [
4675
string,
4776
CapabilitySpec<never, unknown>,

packages/kernel-agents/src/strategies/chat-agent.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,53 @@ describe('makeChatAgent', () => {
155155
).toBe(true);
156156
});
157157

158+
it('injects a tool error for an invalid-argument tool call and continues', async () => {
159+
const add = vi.fn(async (a: number, b: number) => a + b);
160+
const addCap = makeMethodCapability(
161+
'Math',
162+
'add',
163+
add,
164+
S.method(
165+
'Add two numbers',
166+
[S.arg('a', S.number()), S.arg('b', S.number())],
167+
S.number(),
168+
),
169+
);
170+
171+
const recorded: ChatMessage[][] = [];
172+
let call = 0;
173+
const chat: BoundChat = async ({ messages }) => {
174+
recorded.push([...messages]);
175+
call += 1;
176+
if (call === 1) {
177+
// `b` is missing, so the exo's interface guard rejects the call.
178+
return makeToolCallResponse('0', [makeToolCall('c1', 'add', { a: 3 })]);
179+
}
180+
return makeTextResponse('recovered');
181+
};
182+
183+
const agent = makeChatAgent({ chat, capabilities: { add: addCap } });
184+
const result = await agent.task('add 3 and ?');
185+
186+
// The guard rejection surfaces as a tool error rather than crashing the
187+
// task, and the implementation never runs with the bad arguments.
188+
expect(result).toBe('recovered');
189+
expect(add).not.toHaveBeenCalled();
190+
const secondTurn = recorded[1] ?? [];
191+
// The membrane normalizes the rejection into a real error carrying the
192+
// expected signature, so the message is actionable even when the guard
193+
// itself rejects with an opaque value (as it does under the test shim).
194+
expect(
195+
secondTurn.some(
196+
(message) =>
197+
message.role === 'tool' &&
198+
message.content.startsWith(
199+
'Error calling add(a: number, b: number):',
200+
),
201+
),
202+
).toBe(true);
203+
});
204+
158205
it('throws when invocation budget is exceeded', async () => {
159206
const ping = makeMethodCapability(
160207
'Server',

packages/kernel-agents/src/strategies/chat-agent.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -159,47 +159,49 @@ export const makeChatAgent = ({
159159
const { name, arguments: argsJson } = toolCall.function;
160160
logger?.info(`Invoking capability: ${name}`);
161161

162-
const spec = Object.hasOwn(agentCapabilities, name)
163-
? agentCapabilities[name]
164-
: undefined;
165-
if (spec === undefined) {
166-
const errorContent = `Unknown capability "${name}"`;
162+
const pushTool = (content: string): void => {
167163
const toolMsg: ChatMessage = {
168164
role: 'tool',
169165
tool_call_id: toolCall.id,
170-
content: errorContent,
166+
content,
171167
};
172168
chatHistory.push(toolMsg);
173169
history.push(new ChatTurn(toolMsg));
170+
};
171+
172+
const spec = Object.hasOwn(agentCapabilities, name)
173+
? agentCapabilities[name]
174+
: undefined;
175+
if (spec === undefined) {
176+
pushTool(`Unknown capability "${name}"`);
177+
continue;
178+
}
179+
180+
let args: Record<string, unknown>;
181+
try {
182+
args = parseToolArguments(argsJson);
183+
} catch (error) {
184+
// Malformed tool-call JSON from the model — never reaches the
185+
// capability, so the membrane can't frame it; name it here.
186+
const message =
187+
error instanceof Error ? error.message : String(error);
188+
pushTool(`Error calling ${name}: ${message}`);
174189
continue;
175190
}
176191

177192
let toolResult: unknown;
178193
try {
179-
const args = parseToolArguments(argsJson);
180-
// The capability is backed by a pattern-guarded exo, so its
181-
// interface guard enforces the argument shape; a mismatch rejects
182-
// here and is reported as the tool error below.
194+
// The capability is backed by a pattern-guarded exo whose
195+
// interface guard is the sole argument enforcer. On a mismatch the
196+
// membrane rejects with a descriptive `Error calling <signature>:
197+
// …`, which we surface verbatim so the model can self-correct.
183198
toolResult = await spec.func(args as never);
184199
} catch (error) {
185-
const errorContent = `Error calling ${name}: ${(error as Error).message}`;
186-
const toolMsg: ChatMessage = {
187-
role: 'tool',
188-
tool_call_id: toolCall.id,
189-
content: errorContent,
190-
};
191-
chatHistory.push(toolMsg);
192-
history.push(new ChatTurn(toolMsg));
200+
pushTool(error instanceof Error ? error.message : String(error));
193201
continue;
194202
}
195203

196-
const toolMsg: ChatMessage = {
197-
role: 'tool',
198-
tool_call_id: toolCall.id,
199-
content: JSON.stringify(toolResult),
200-
};
201-
chatHistory.push(toolMsg);
202-
history.push(new ChatTurn(toolMsg));
204+
pushTool(JSON.stringify(toolResult));
203205
}
204206
}
205207
throw new Error('Invocation budget exceeded');

0 commit comments

Comments
 (0)