Skip to content

Emit synthetic tool-result on stream abort/error so in-flight tools surface as failures #778

@jorgeraad

Description

@jorgeraad

When OffensiveSecurityAgent.consume() (packages/apex/src/core/agents/offSecAgent/offensiveSecurityAgent.ts:449-458) is iterating streamResult.fullStream and the iterator throws — AbortSignal fired (endpoint timer, scan-level timer, user abort), idle-stream timeout, or a transport error — any tool whose tool-call-complete was emitted but whose tool-result had not yet streamed is silently dropped. The bus emits nothing for it on the way out. Downstream in-process consumers (TUI rendering, session persistence on disk, and any external subscribers attached to a parent bus) are left with a tool-call that has no matching result.

Proposal

Track in-flight tool calls inside consume() (saw tool-call-complete for a toolCallId but not yet tool-result). On stream abort/exception, emit synthetic tool-result events for each, reusing the existing AI SDK output envelope:

bus.emit('tool-result', {
  toolCallId,
  toolName,
  result: {
    type: 'error-text',
    value: `Tool execution aborted: ${abortReason}. ${err.message ?? ''}`,
  },
  subagentId: this.subagentId,
});

error-text (and its sibling error-json) is already a first-class variant in the SDK's tool-output discriminated union (packages/apex/src/core/messages/types.ts:61-77) and is already handled across the codebase:

  • packages/apex/src/core/session/persistence.ts:419 (unwrapAiSdkToolOutput)
  • packages/apex/src/core/ai/providers/pensarFormatters.ts:128
  • packages/apex/src/core/agents/offSecAgent/trace.ts:285-291

No new event types, no new fields, no new flags. Same shape the SDK itself produces when a tool's execute() throws.

Local TUI / session persistence

messagesPath is written from latestMessages (offensiveSecurityAgent.ts:352-356, 381-388), which mirrors the SDK's response.messages. Synthetic bus emissions alone won't make it into that file — meaning a resumed session or TUI replay would still be missing the aborted tools' results.

To keep local persistence consistent: in the same catch/finally block that emits the synthetic bus events, also append corresponding tool-result content parts to latestMessages so the next writeFile picks them up. The shape going into latestMessages should match what the SDK would have produced — a { role: 'tool', content: [{ type: 'tool-result', toolCallId, toolName, output: { type: 'error-text', value: '...' } }] } message. Then unwrapAiSdkToolOutput handles it correctly on resume just like a real tool error.

In-flight tracking — keep it local

The tracking Map lives inside consume(). No new abstractions, no new class members beyond what's needed. When per-call lifecycle becomes a first-class concept in future restructuring, this inline state goes away cleanly.

Out of scope

  • Run-level abort propagation (the workflow catching the throw and marking the run terminal) — already handled by callers.
  • Any new bus event types or new role values — explicit non-goal, the existing tool-result event and SDK envelope are sufficient.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions