A throwing tool ends the invocation in adk-python and is recovered from in adk-js
- Dominant language
- TypeScript
- Stars
- 1.4k
- Forks
- 205
- Avg merge
- 3d 16h
- Merged PRs (30d)
- 92
Description
A tool that throws ends the invocation in adk-python and is recovered from in adk-js. Both runtimes reach the same decision point through the same hook, and take opposite branches when no plugin handles the error.
`core/src/agents/functions.ts:558-585`:
```ts
try {
functionResponse = await callToolAsync(tool, functionArgs, toolContext);
} catch (e: unknown) {
const onToolErrorResponse = await invocationContext.pluginManager.runOnToolErrorCallback({...});
if (onToolErrorResponse != null) {
functionResponse = normalizeCallbackResponse(onToolErrorResponse);
} else {
functionResponseError = e.message; // continue with an error response
}
}
```
`adk-python`, `src/google/adk/flows/llm_flows/_tool_caller.py:776-790`:
```python
try:
function_response = await tool_runner()
except Exception as tool_error:
error_response = await _tool_error_handler.run_on_tool_error_callbacks(...)
if error_response is not None:
function_response = error_response
else:
raise tool_error # invocation ends here
```
### Reproduction
A scripted model, one tool that raises, no plugins. TypeScript:
```ts
const raises = new FunctionTool({
name: 'raises', description: 'Always fails.',
parameters: z.object({x: z.number()}),
execute: () => { throw new Error('tool blew up'); },
});
// model script: [ functionCall('raises', {x: 1}), 'done' ]
```
```
call:raises | response:raises(error) | text:done ← runAsync completes
```
Python, same script and tool through `InMemoryRunner.run_async`:
```
call:raises | ValueError: tool blew up ← run_async raises
```
### Where the two agree, and where they do not
Seven scripted-model scenarios, no plugins registered:
| scenario | adk-python | adk-js |
| --- | --- | --- |
| model calls an unregistered tool | error response, turn continues | error response, turn continues |
| unregistered tool beside a registered one | both answered, turn continues | both answered, turn continues |
| function call with an empty name | error response, turn continues | error response, turn continues |
| required argument missing | error response, turn continues | error response, turn continues |
| **tool raises** | **invocation raises** | error response, turn continues |
| **tool raises beside a succeeding tool** | **invocation raises** | error response, turn continues |
| **argument of the wrong type** | **invocation raises** (`TypeError` from the tool body) | error response, turn continues |
The last row differs for a second reason: `FunctionTool` validates arguments against the parameter schema in TypeScript, so a wrongly typed argument never reaches the function. In Python it does, and whatever the body raises propagates.
### Which one is intended
I could not tell from the code, so I have not sent a patch.
Python recovering from a tool it cannot find but not from a tool that fails reads as an inconsistency inside one runtime — `_tool_caller.py:707-711` builds a not-found response for the lookup failure a few lines above the `raise`. Against that, `raise tool_error` is explicit rather than incidental, and the callback is documented as an opportunity to "handle tool errors gracefully", which reads as: unhandled means not handled.
The difference matters either way. An agent that recovers gives the model the error and a chance to correct itself; an agent that raises surfaces the failure to the caller. Code written against one runtime does the wrong thing on the other, and a plugin registered only to keep an invocation alive is a no-op in TypeScript and load-bearing in Python.
Happy to send the patch once you say which side should move.
Contributor guide
Assessment
This issue has not been assessed yet.