fix(engine): stop a failed child-agent call from killing the whole run - #76
fix(engine): stop a failed child-agent call from killing the whole run#76AmitAvital1 wants to merge 1 commit into
Conversation
49155e5 to
16e181c
Compare
|
Thanks — the core direction makes sense. A failure in an I found one blocking concern with the new run-failure logging: message=str(exc)
The hook manager intentionally logs only the exception type, and there is already a test verifying that hook exception messages are not leaked. Logging Please remove the raw exception message from these log fields, or introduce an explicitly sanitized representation that cannot include the wrapped cause text. Please also add a regression test for the actual behavior this PR changes: an Once those points are addressed, the overall change looks good. |
16e181c to
9bbc3ae
Compare
9bbc3ae to
f760d68
Compare
|
Thanks for the catch — fixed at the source rather than in the log call. Leak fix: Regression tests added:
All pushed. Full suite + lint + typecheck green. |
|
One more round based on additional feedback: the model-facing "Agent error: {exc}" string (both where a child's whole run fails, and the malformed-args case fixed earlier) was raw, inconsistent exception text — not something a model can reliably act on — and neither catch site logged anything server-side at all, so for a nested child failure the real detail was completely lost, not just under-detailed. Changed both to:
Added |
|
Follow-up: the fixed string from the previous commit was safe but not actionable. In the original incident the failure was the model's own malformed call (a dict where Schema rejections now return the offending field and expected shape, built from Actionable for the model, and its own payload is never handed back to it. Other failure kinds keep their existing treatment deliberately:
Same treatment applied to Test asserts all three properties: the run survives, the model receives the actionable field-level reason, and the sentinel argument value never appears in what it was handed. |
02c0ae1 to
6d70ba2
Compare
|
Ran a full review pass over this branch. It found a leak I introduced in this PR, plus the original incident's log line still being live. Both fixed; still one squashed commit. Fixed — credential leak into server logs (introduced here)The I had closed the model channel and reopened the same data on the log channel — the Fixed —
|
6d70ba2 to
4cf06bb
Compare
|
Ran a fresh review pass against the current state. It found two more real issues — both verified myself, both fixed. Blocking — the leak fix from last round was incomplete`AgentNode._record_error` (nodes.py:362, untouched until now) still had `error = str(exc)[:200]` — the exact same class of leak, one call site away from where it was fixed. That raw string flowed into the WARNING log, `ToolUsageRecord.error`, and the `on_tool_error` hook payload delivered to arbitrary plugin code. Verified live with a pydantic model containing a `password` field — leaked before, clean after: ``` after: tool call failed ... error="password: Input should be a valid string" Now uses the same `safe_error()` as everywhere else. Also applied it to the two `message=str(exc)` fields in `engine.py` for consistency (not currently exploitable, but no reason to leave one exception-rendering path different from the rest). Blocking — my own fix widened a fail-open gap`OrchestratorNode.invoke_tool` had zero try/except on `main` — confirmed via `git show origin/main`. That meant a `HookExecutionError` from a fail-closed `before_tool_call` hook used to propagate uncaught and correctly abort the run. The catch I added to fix the actual bug (malformed tool args) also caught that exception type, silently converting a security gate into a no-op for any child agent called directly by an orchestrator. Both `invoke_tool` and the sibling `_make_tool.invoke` (which already had this problem pre-existing, now confirmed and not just assumed) now re-raise `HookExecutionError` explicitly before the generic catch, so a fail-closed hook still aborts the run at both call sites. Added `test_fail_closed_before_tool_call_aborts_run_under_orchestrator` and confirmed it fails without the re-raise (child agent under an orchestrator, hook raises, run used to complete normally — now correctly raises). Rebased onto `main` (picked up the new YAML unknown-key validation, no conflicts). Lint, typecheck, full suite green. |
An OrchestratorNode child-agent call had no exception handling at all, unlike its sibling AgentNode: an uncaught error (a validation failure on malformed tool args, a downed child model, ...) crashed the entire run with a bare 500 and no assistant reply. - OrchestratorNode.invoke_tool and _make_tool.invoke now catch child failures, log them, and return a message so the model can recover instead of the run dying. GraphInterrupt (HITL) and HookExecutionError from a fail-closed hook (e.g. before_tool_call) are re-raised — a security gate rejecting a call must still abort the run, not be reported as a recoverable child failure. - Hook failure_policy (fail-closed vs warn) is now a proper FailurePolicy enum instead of a magic string, with a per-hook-point default table. on_tool_error now defaults to warn: a broken error-reporting hook should not itself crash an otherwise-recoverable run, while security-critical points (before_tool_call, etc.) stay fail-closed. - Exception text logged anywhere (engine run/stream failures, the observability trace callback, tool-call failures) now goes through a new safe_error()/log() path instead of raw str(exc). A pydantic ValidationError's str() echoes back the rejected input_value — the caller's own arguments, which may hold credentials — so error logging reduces it to field names/reasons instead of the raw payload. - HookExecutionError's own message no longer repeats the wrapped cause's text, for the same reason. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4cf06bb to
6eff656
Compare
What
An
OrchestratorNodechild-agent call had no exception handling at all, unlike its siblingAgentNode. An uncaught error (e.g. apydantic.ValidationErrorfrom malformed tool args, or a downed child model) crashed the entire run — a bare HTTP 500 with no assistant reply and no useful log.This PR fixes that, plus two things found while investigating: hook failure policy was a magic string with no configurability, and exception text was logged raw in several places, leaking payload data (e.g. a validation error's
input_value— the caller's own arguments, which may contain credentials).Changes
OrchestratorNode.invoke_tooland_make_tool.invokenow catch child-agent failures, log them, and return a message so the model/orchestrator can recover — instead of the run dying.GraphInterrupt(HITL control flow) andHookExecutionErrorfrom a fail-closed hook (e.g.before_tool_call) are explicitly re-raised: a security gate rejecting a call must still abort the run, not be silently reported as a recoverable child failure.failure_policyis now aFailurePolicyenum (FAIL/WARN) instead of a magic string, with a per-hook-point default table (DEFAULT_FAILURE_POLICY).on_tool_errornow defaults towarn— a broken error-reporting hook shouldn't itself crash an otherwise-recoverable run — while security-critical points (before_tool_call, etc.) stay fail-closed by default. Still fully configurable per hook.safe_error()/validation_problems()utility inlogging_config.py, now used everywhere an exception is logged (engine run/stream failures, the observability trace callback, tool-call failures).log()itself sanitizes anyBaseExceptionvalue passed as a field. A pydanticValidationError'sstr()echoes back the rejectedinput_value; this reduces it to field names/reasons instead.HookExecutionError.__str__no longer repeats the wrapped cause's own message, for the same reason — its text is safe to log or return in an API error anywhere.RUNTIME_HOOKS.md,runtime-hooks.mdx,config.schema.json) updated for theon_tool_errordefault change.Follow-up
A stacked PR on top of this one improves the actual content of the message a failed child-agent call returns to the model (field-level actionable detail instead of a generic notice) — kept separate to keep this fix reviewable on its own.
Test plan
test_on_tool_error_hook_failure_does_not_abort_run_by_default,test_on_tool_error_hook_failure_with_fail_aborts_run,test_fail_closed_before_tool_call_aborts_run_under_orchestrator,test_orchestrator_recovers_from_child_agent_crash,test_hook_execution_error_str_omits_cause_message,test_log_helper_renders_exception_fields_without_payloadmake lint,make typecheck,make testall pass🤖 Generated with Claude Code