diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 04c4a9a7..b1106f5c 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -727,6 +727,7 @@ type API struct { onToolCall func(func(ToolCallEvent, Context) *ToolCallResult) onToolExecStart func(func(ToolExecutionStartEvent, Context)) onToolExecEnd func(func(ToolExecutionEndEvent, Context)) + onToolOutput func(func(ToolOutputEvent, Context)) onToolResult func(func(ToolResultEvent, Context) *ToolResultResult) onInput func(func(InputEvent, Context) *InputResult) onBeforeAgentStart func(func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult) @@ -767,6 +768,13 @@ func (a *API) OnToolExecutionEnd(handler func(ToolExecutionEndEvent, Context)) { a.onToolExecEnd(handler) } +// OnToolOutput registers a handler for streaming tool output chunks. +// This fires for each output line as it arrives from tools like bash, +// allowing extensions to observe or process output in real-time. +func (a *API) OnToolOutput(handler func(ToolOutputEvent, Context)) { + a.onToolOutput(handler) +} + // OnToolResult registers a handler that fires after tool execution. // Return a non-nil ToolResultResult to modify the output. func (a *API) OnToolResult(handler func(ToolResultEvent, Context) *ToolResultResult) { @@ -1538,6 +1546,19 @@ type ToolExecutionEndEvent struct { func (e ToolExecutionEndEvent) Type() EventType { return ToolExecutionEnd } +// ToolOutputEvent fires when a tool produces streaming output chunks. +// This is primarily used for long-running tools like bash to show output +// in real-time as it arrives, before the tool completes. +type ToolOutputEvent struct { + ToolCallID string + ToolName string + ToolKind string + Chunk string // Output text chunk + IsStderr bool // Whether this chunk came from stderr +} + +func (e ToolOutputEvent) Type() EventType { return ToolOutput } + // ToolResultEvent fires after tool execution with the output. type ToolResultEvent struct { ToolCallID string diff --git a/internal/extensions/events.go b/internal/extensions/events.go index 5d50dd64..f834d8e2 100644 --- a/internal/extensions/events.go +++ b/internal/extensions/events.go @@ -19,6 +19,9 @@ const ( // ToolExecutionEnd fires when a tool finishes executing. ToolExecutionEnd EventType = "tool_execution_end" + // ToolOutput fires when a tool produces streaming output chunks. + ToolOutput EventType = "tool_output" + // ToolResult fires after a tool executes. Handlers can modify the result. ToolResult EventType = "tool_result" diff --git a/internal/extensions/loader.go b/internal/extensions/loader.go index 165a81c4..7a6d6563 100644 --- a/internal/extensions/loader.go +++ b/internal/extensions/loader.go @@ -439,6 +439,12 @@ func loadSingleExtension(path string) (*LoadedExtension, error) { return nil }) }, + onToolOutput: func(h func(ToolOutputEvent, Context)) { + reg(ToolOutput, func(e Event, c Context) Result { + h(e.(ToolOutputEvent), c) + return nil + }) + }, onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) { reg(ToolResult, func(e Event, c Context) Result { r := h(e.(ToolResultEvent), c) diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index afb16c7b..bec8bf78 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -128,6 +128,7 @@ func Symbols() interp.Exports { "ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)), "ToolExecutionStartEvent": reflect.ValueOf((*ToolExecutionStartEvent)(nil)), "ToolExecutionEndEvent": reflect.ValueOf((*ToolExecutionEndEvent)(nil)), + "ToolOutputEvent": reflect.ValueOf((*ToolOutputEvent)(nil)), "ToolResultEvent": reflect.ValueOf((*ToolResultEvent)(nil)), "ToolResultResult": reflect.ValueOf((*ToolResultResult)(nil)), "InputEvent": reflect.ValueOf((*InputEvent)(nil)), diff --git a/internal/extensions/test_api.go b/internal/extensions/test_api.go index 1ca7b0d6..60939a1e 100644 --- a/internal/extensions/test_api.go +++ b/internal/extensions/test_api.go @@ -30,6 +30,12 @@ func NewTestAPI(ext *LoadedExtension) API { return nil }) }, + onToolOutput: func(h func(ToolOutputEvent, Context)) { + reg(ToolOutput, func(e Event, c Context) Result { + h(e.(ToolOutputEvent), c) + return nil + }) + }, onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) { reg(ToolResult, func(e Event, c Context) Result { r := h(e.(ToolResultEvent), c) diff --git a/pkg/kit/events.go b/pkg/kit/events.go index c83e364b..37f02ca1 100644 --- a/pkg/kit/events.go +++ b/pkg/kit/events.go @@ -335,6 +335,16 @@ func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() { }) } +// OnToolOutput registers a handler that fires only for ToolOutputEvent +// (streaming tool output chunks, e.g., from bash). Returns an unsubscribe function. +func (m *Kit) OnToolOutput(handler func(ToolOutputEvent)) func() { + return m.Subscribe(func(e Event) { + if to, ok := e.(ToolOutputEvent); ok { + handler(to) + } + }) +} + // OnStreaming registers a handler that fires only for MessageUpdateEvent // (streaming text chunks). Returns an unsubscribe function. func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() { diff --git a/pkg/kit/extensions_bridge.go b/pkg/kit/extensions_bridge.go index 162eab18..2b26415d 100644 --- a/pkg/kit/extensions_bridge.go +++ b/pkg/kit/extensions_bridge.go @@ -86,6 +86,20 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) { }) } + // Tool output streaming events (observation only). + if runner.HasHandlers(extensions.ToolOutput) { + m.Subscribe(func(e Event) { + if ev, ok := e.(ToolOutputEvent); ok { + _, _ = runner.Emit(extensions.ToolOutputEvent{ + ToolCallID: ev.ToolCallID, + ToolName: ev.ToolName, + Chunk: ev.Chunk, + IsStderr: ev.IsStderr, + }) + } + }) + } + if runner.HasHandlers(extensions.AgentEnd) { m.Subscribe(func(e Event) { if ev, ok := e.(TurnEndEvent); ok {