From 4ba9d6fab313cbd204be7fe8099dca46b37f8ccc Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 21 Apr 2026 23:28:13 +0300 Subject: [PATCH] feat(events): mirror Fantasy tool input streaming callbacks as Kit events - Add ToolCallStartEvent, ToolCallDeltaEvent, ToolCallEndEvent to SDK - Wire Fantasy OnToolInputStart/Delta/End through agent to EventBus - Add typed convenience subscribers: OnToolCallStart/Delta/End on Kit - Bridge new events to TUI via ToolCallInputStart/Delta/End app events - Extend extension system with OnToolCallInputStart/Delta/End handlers - Add extension event types, API methods, loader wiring, Yaegi symbols - Update docs: README, SDK skill, extensions skill, www/sdk, www/extensions Closes #16 --- README.md | 2 +- internal/agent/agent.go | 50 ++++++++++++++++++- internal/app/app.go | 6 +++ internal/app/events.go | 30 +++++++++++ internal/extensions/api.go | 51 +++++++++++++++++++ internal/extensions/events.go | 16 +++++- internal/extensions/events_test.go | 7 ++- internal/extensions/loader.go | 18 +++++++ internal/extensions/symbols.go | 3 ++ pkg/kit/events.go | 75 ++++++++++++++++++++++++++++ pkg/kit/extensions_bridge.go | 32 ++++++++++++ pkg/kit/kit.go | 19 +++++++ skills/kit-extensions/SKILL.md | 33 +++++++++++- skills/kit-sdk/SKILL.md | 24 +++++++++ www/pages/extensions/capabilities.md | 5 +- www/pages/sdk/callbacks.md | 31 +++++++++++- 16 files changed, 393 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0578f09f..0c43d8f7 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ kit -e examples/extensions/minimal.go ### Extension Capabilities -**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd +**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolCallInputStart, OnToolCallInputDelta, OnToolCallInputEnd, OnToolExecutionStart, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd **Custom Components**: - **Tools**: Add new tools the LLM can invoke diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8a5bd55f..1da108d5 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -87,6 +87,19 @@ type ReasoningDeltaHandler func(delta string) // Called when the last reasoning token has been processed, before text streaming starts. type ReasoningCompleteHandler func() +// ToolCallStartHandler is a function type for handling the moment when the LLM +// begins generating tool call arguments. The tool name is known but the full +// argument JSON is still streaming. +type ToolCallStartHandler func(toolCallID, toolName string) + +// ToolCallDeltaHandler is a function type for handling streamed fragments of +// tool call arguments as they arrive from the LLM. +type ToolCallDeltaHandler func(toolCallID, delta string) + +// ToolCallEndHandler is a function type for handling the end of tool argument +// streaming, before the tool call is parsed and execution begins. +type ToolCallEndHandler func(toolCallID string) + // ToolOutputHandler is a function type for handling streaming tool output chunks. // Used by tools like bash to stream output as it arrives rather than waiting // for the command to complete. The isStderr flag indicates if the chunk @@ -411,7 +424,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message onResponse ResponseHandler, onToolCallContent ToolCallContentHandler, ) (*GenerateWithLoopResult, error) { return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult, - onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil, nil) + onResponse, onToolCallContent, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) } // GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks. @@ -427,6 +440,9 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan onStepMessages StepMessagesHandler, onStepUsage StepUsageHandler, onPasswordPrompt PasswordPromptHandler, + onToolCallStart ToolCallStartHandler, + onToolCallDelta ToolCallDeltaHandler, + onToolCallEnd ToolCallEndHandler, ) (*GenerateWithLoopResult, error) { // Wait for background MCP tool loading to complete and rebuild the @@ -462,7 +478,8 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan // Stream is required to observe tool execution in real time. The non-streaming // Generate path is reserved for the simple case with no callbacks at all. hasCallbacks := onToolCall != nil || onToolExecution != nil || onToolResult != nil || - onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil + onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil || + onToolCallStart != nil || onToolCallDelta != nil || onToolCallEnd != nil if a.streamingEnabled || hasCallbacks { // Track completed step messages so we can return partial results @@ -481,6 +498,35 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan Files: files, Messages: history, + // Tool input streaming callbacks — fire during tool argument generation + OnToolInputStart: func(id, toolName string) error { + if ctx.Err() != nil { + return ctx.Err() + } + if onToolCallStart != nil { + onToolCallStart(id, toolName) + } + return nil + }, + OnToolInputDelta: func(id, delta string) error { + if ctx.Err() != nil { + return ctx.Err() + } + if onToolCallDelta != nil { + onToolCallDelta(id, delta) + } + return nil + }, + OnToolInputEnd: func(id string) error { + if ctx.Err() != nil { + return ctx.Err() + } + if onToolCallEnd != nil { + onToolCallEnd(id) + } + return nil + }, + // Reasoning/thinking streaming callback OnReasoningDelta: func(id, delta string) error { if ctx.Err() != nil { diff --git a/internal/app/app.go b/internal/app/app.go index e2d04e18..20ea9e39 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -888,6 +888,12 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg), stepUsageSeen *atomic.Boo switch ev := e.(type) { case kit.ToolCallEvent: sendFn(ToolCallStartedEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs}) + case kit.ToolCallStartEvent: + sendFn(ToolCallInputStartEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolKind: ev.ToolKind}) + case kit.ToolCallDeltaEvent: + sendFn(ToolCallInputDeltaEvent{ToolCallID: ev.ToolCallID, Delta: ev.Delta}) + case kit.ToolCallEndEvent: + sendFn(ToolCallInputEndEvent{ToolCallID: ev.ToolCallID}) case kit.ToolExecutionStartEvent: sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true}) case kit.ToolExecutionEndEvent: diff --git a/internal/app/events.go b/internal/app/events.go index b61e4364..e56226d4 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -32,6 +32,36 @@ type ToolCallStartedEvent struct { ToolArgs string } +// ToolCallInputStartEvent is sent when the LLM begins generating tool call +// arguments. The tool name is known but the full argument JSON is still being +// streamed. UIs can use this to show a "running" indicator immediately instead +// of waiting for the full argument JSON to finish streaming. +type ToolCallInputStartEvent struct { + // ToolCallID is the stable identifier for correlating tool lifecycle events. + ToolCallID string + // ToolName is the name of the tool being called. + ToolName string + // ToolKind classifies the tool: "execute", "edit", "read", "search", "agent". + ToolKind string +} + +// ToolCallInputDeltaEvent is sent for each streamed fragment of tool call +// arguments as they arrive from the LLM. Useful for live-previewing content +// or showing a progress indicator with byte count. +type ToolCallInputDeltaEvent struct { + // ToolCallID is the stable identifier for correlating tool lifecycle events. + ToolCallID string + // Delta is a JSON fragment of tool call arguments. + Delta string +} + +// ToolCallInputEndEvent is sent when tool argument streaming is complete, +// before the tool call is parsed and execution begins. +type ToolCallInputEndEvent struct { + // ToolCallID is the stable identifier for correlating tool lifecycle events. + ToolCallID string +} + // ToolExecutionEvent is sent when a tool starts or finishes executing. // The IsStarting flag distinguishes between the start and end of execution. type ToolExecutionEvent struct { diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 8fa1565c..95583db1 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -1063,6 +1063,9 @@ type PrintBlockOpts struct { type API struct { // Event-specific registration functions (wired by the loader). onToolCall func(func(ToolCallEvent, Context) *ToolCallResult) + onToolCallInputStart func(func(ToolCallInputStartEvent, Context)) + onToolCallInputDelta func(func(ToolCallInputDeltaEvent, Context)) + onToolCallInputEnd func(func(ToolCallInputEndEvent, Context)) onToolExecStart func(func(ToolExecutionStartEvent, Context)) onToolExecEnd func(func(ToolExecutionEndEvent, Context)) onToolOutput func(func(ToolOutputEvent, Context)) @@ -1099,6 +1102,26 @@ func (a *API) OnToolCall(handler func(ToolCallEvent, Context) *ToolCallResult) { a.onToolCall(handler) } +// OnToolCallInputStart registers a handler that fires when the LLM begins +// generating tool call arguments. The tool name is known but the full +// argument JSON is still being streamed. Useful for showing a "running" +// indicator immediately without waiting for the full arguments. +func (a *API) OnToolCallInputStart(handler func(ToolCallInputStartEvent, Context)) { + a.onToolCallInputStart(handler) +} + +// OnToolCallInputDelta registers a handler that fires for each streamed +// fragment of tool call arguments as they arrive from the LLM. +func (a *API) OnToolCallInputDelta(handler func(ToolCallInputDeltaEvent, Context)) { + a.onToolCallInputDelta(handler) +} + +// OnToolCallInputEnd registers a handler that fires when tool argument +// streaming is complete, before the tool call is parsed and execution begins. +func (a *API) OnToolCallInputEnd(handler func(ToolCallInputEndEvent, Context)) { + a.onToolCallInputEnd(handler) +} + // OnToolExecutionStart registers a handler for tool execution start. func (a *API) OnToolExecutionStart(handler func(ToolExecutionStartEvent, Context)) { a.onToolExecStart(handler) @@ -1890,6 +1913,34 @@ type ToolCallResult struct { func (ToolCallResult) isResult() {} +// ToolCallInputStartEvent fires when the LLM begins generating tool call +// arguments. The tool name is known but the full argument JSON is still +// being streamed. +type ToolCallInputStartEvent struct { + ToolCallID string + ToolName string + ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent" +} + +func (e ToolCallInputStartEvent) Type() EventType { return ToolCallInputStart } + +// ToolCallInputDeltaEvent fires for each streamed fragment of tool call +// arguments as they arrive from the LLM. +type ToolCallInputDeltaEvent struct { + ToolCallID string + Delta string // JSON fragment of tool arguments +} + +func (e ToolCallInputDeltaEvent) Type() EventType { return ToolCallInputDelta } + +// ToolCallInputEndEvent fires when tool argument streaming is complete, +// before the tool call is parsed and execution begins. +type ToolCallInputEndEvent struct { + ToolCallID string +} + +func (e ToolCallInputEndEvent) Type() EventType { return ToolCallInputEnd } + // ToolExecutionStartEvent fires when a tool begins executing. type ToolExecutionStartEvent struct { ToolCallID string diff --git a/internal/extensions/events.go b/internal/extensions/events.go index 7ee89630..dde92158 100644 --- a/internal/extensions/events.go +++ b/internal/extensions/events.go @@ -13,6 +13,19 @@ const ( // ToolCall fires before a tool executes. Handlers can block execution. ToolCall EventType = "tool_call" + // ToolCallInputStart fires when the LLM begins generating tool call + // arguments. The tool name is known but the full argument JSON is still + // being streamed. + ToolCallInputStart EventType = "tool_call_input_start" + + // ToolCallInputDelta fires for each streamed fragment of tool call + // arguments as they arrive from the LLM. + ToolCallInputDelta EventType = "tool_call_input_delta" + + // ToolCallInputEnd fires when tool argument streaming is complete, + // before the tool call is parsed and execution begins. + ToolCallInputEnd EventType = "tool_call_input_end" + // ToolExecutionStart fires when a tool begins executing. ToolExecutionStart EventType = "tool_execution_start" @@ -88,7 +101,8 @@ const ( // AllEventTypes returns every supported event type. func AllEventTypes() []EventType { return []EventType{ - ToolCall, ToolExecutionStart, ToolExecutionEnd, ToolResult, + ToolCall, ToolCallInputStart, ToolCallInputDelta, ToolCallInputEnd, + ToolExecutionStart, ToolExecutionEnd, ToolResult, Input, BeforeAgentStart, AgentStart, AgentEnd, MessageStart, MessageUpdate, MessageEnd, SessionStart, SessionShutdown, diff --git a/internal/extensions/events_test.go b/internal/extensions/events_test.go index 3e0c1954..0432bf4f 100644 --- a/internal/extensions/events_test.go +++ b/internal/extensions/events_test.go @@ -4,8 +4,8 @@ import "testing" func TestAllEventTypes_Count(t *testing.T) { all := AllEventTypes() - if len(all) != 21 { - t.Fatalf("expected 21 event types, got %d", len(all)) + if len(all) != 24 { + t.Fatalf("expected 24 event types, got %d", len(all)) } } @@ -38,6 +38,9 @@ func TestEventType_TypeMethod(t *testing.T) { want EventType }{ {ToolCallEvent{ToolName: "test"}, ToolCall}, + {ToolCallInputStartEvent{ToolCallID: "x", ToolName: "test"}, ToolCallInputStart}, + {ToolCallInputDeltaEvent{ToolCallID: "x", Delta: "{"}, ToolCallInputDelta}, + {ToolCallInputEndEvent{ToolCallID: "x"}, ToolCallInputEnd}, {ToolExecutionStartEvent{ToolName: "test"}, ToolExecutionStart}, {ToolExecutionEndEvent{ToolName: "test"}, ToolExecutionEnd}, {ToolResultEvent{ToolName: "test"}, ToolResult}, diff --git a/internal/extensions/loader.go b/internal/extensions/loader.go index 729623d8..91bae920 100644 --- a/internal/extensions/loader.go +++ b/internal/extensions/loader.go @@ -429,6 +429,24 @@ func loadSingleExtension(path string) (*LoadedExtension, error) { return *r }) }, + onToolCallInputStart: func(h func(ToolCallInputStartEvent, Context)) { + reg(ToolCallInputStart, func(e Event, c Context) Result { + h(e.(ToolCallInputStartEvent), c) + return nil + }) + }, + onToolCallInputDelta: func(h func(ToolCallInputDeltaEvent, Context)) { + reg(ToolCallInputDelta, func(e Event, c Context) Result { + h(e.(ToolCallInputDeltaEvent), c) + return nil + }) + }, + onToolCallInputEnd: func(h func(ToolCallInputEndEvent, Context)) { + reg(ToolCallInputEnd, func(e Event, c Context) Result { + h(e.(ToolCallInputEndEvent), c) + return nil + }) + }, onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) { reg(ToolExecutionStart, func(e Event, c Context) Result { h(e.(ToolExecutionStartEvent), c) diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 06a1b4e9..51fdf855 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -152,6 +152,9 @@ func Symbols() interp.Exports { // Event structs "ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)), "ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)), + "ToolCallInputStartEvent": reflect.ValueOf((*ToolCallInputStartEvent)(nil)), + "ToolCallInputDeltaEvent": reflect.ValueOf((*ToolCallInputDeltaEvent)(nil)), + "ToolCallInputEndEvent": reflect.ValueOf((*ToolCallInputEndEvent)(nil)), "ToolExecutionStartEvent": reflect.ValueOf((*ToolExecutionStartEvent)(nil)), "ToolExecutionEndEvent": reflect.ValueOf((*ToolExecutionEndEvent)(nil)), "ToolOutputEvent": reflect.ValueOf((*ToolOutputEvent)(nil)), diff --git a/pkg/kit/events.go b/pkg/kit/events.go index 96bafcde..dd955163 100644 --- a/pkg/kit/events.go +++ b/pkg/kit/events.go @@ -23,6 +23,14 @@ const ( EventMessageUpdate EventType = "message_update" // EventMessageEnd fires when the assistant message is complete. EventMessageEnd EventType = "message_end" + // EventToolCallStart fires when the LLM begins generating tool call arguments. + // The tool name is known but arguments are still streaming. + EventToolCallStart EventType = "tool_call_start" + // EventToolCallDelta fires for each streamed fragment of tool call arguments. + EventToolCallDelta EventType = "tool_call_delta" + // EventToolCallEnd fires when tool argument streaming is complete, before + // the tool call is parsed and execution begins. + EventToolCallEnd EventType = "tool_call_end" // EventToolCall fires when a tool call has been parsed and is about to execute. EventToolCall EventType = "tool_call" // EventToolExecutionStart fires when a tool begins executing. @@ -216,6 +224,40 @@ type MessageEndEvent struct { // EventType implements Event. func (e MessageEndEvent) EventType() EventType { return EventMessageEnd } +// ToolCallStartEvent fires when the LLM begins generating tool call arguments. +// The tool name is known at this point but the full arguments are still being +// streamed. UIs can use this to show a "running" indicator immediately instead +// of waiting for the full argument JSON to finish streaming. +type ToolCallStartEvent struct { + ToolCallID string // Stable ID for correlating tool lifecycle events + ToolName string + ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent" +} + +// EventType implements Event. +func (e ToolCallStartEvent) EventType() EventType { return EventToolCallStart } + +// ToolCallDeltaEvent fires for each streamed fragment of tool call arguments. +// Useful for live-previewing artifact content as it's generated, or showing a +// progress indicator with byte count. +type ToolCallDeltaEvent struct { + ToolCallID string // Stable ID for correlating tool lifecycle events + Delta string // JSON fragment of tool arguments +} + +// EventType implements Event. +func (e ToolCallDeltaEvent) EventType() EventType { return EventToolCallDelta } + +// ToolCallEndEvent fires when tool argument streaming is complete, before +// the tool call is parsed and execution begins. UIs can use this to +// transition from an "generating args" state to an "executing" state. +type ToolCallEndEvent struct { + ToolCallID string // Stable ID for correlating tool lifecycle events +} + +// EventType implements Event. +func (e ToolCallEndEvent) EventType() EventType { return EventToolCallEnd } + // ToolCallEvent fires when a tool call has been parsed. type ToolCallEvent struct { ToolCallID string // Stable ID for correlating tool lifecycle events @@ -420,6 +462,39 @@ func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() { }) } +// OnToolCallStart registers a handler that fires only for ToolCallStartEvent. +// This fires when the LLM begins generating tool call arguments — before the +// full argument JSON is available. Returns an unsubscribe function. +func (m *Kit) OnToolCallStart(handler func(ToolCallStartEvent)) func() { + return m.Subscribe(func(e Event) { + if tcs, ok := e.(ToolCallStartEvent); ok { + handler(tcs) + } + }) +} + +// OnToolCallDelta registers a handler that fires only for ToolCallDeltaEvent. +// Each delta contains a JSON fragment of tool call arguments as they stream in. +// Returns an unsubscribe function. +func (m *Kit) OnToolCallDelta(handler func(ToolCallDeltaEvent)) func() { + return m.Subscribe(func(e Event) { + if tcd, ok := e.(ToolCallDeltaEvent); ok { + handler(tcd) + } + }) +} + +// OnToolCallEnd registers a handler that fires only for ToolCallEndEvent. +// This fires when tool argument streaming is complete, before the tool call +// is parsed and execution begins. Returns an unsubscribe function. +func (m *Kit) OnToolCallEnd(handler func(ToolCallEndEvent)) func() { + return m.Subscribe(func(e Event) { + if tce, ok := e.(ToolCallEndEvent); ok { + handler(tce) + } + }) +} + // OnToolResult registers a handler that fires only for ToolResultEvent. // Returns an unsubscribe function. func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() { diff --git a/pkg/kit/extensions_bridge.go b/pkg/kit/extensions_bridge.go index b50c6b9d..7825d888 100644 --- a/pkg/kit/extensions_bridge.go +++ b/pkg/kit/extensions_bridge.go @@ -100,6 +100,38 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) { }) } + // Tool call input streaming events — fire as the LLM generates tool arguments. + if runner.HasHandlers(extensions.ToolCallInputStart) { + m.Subscribe(func(e Event) { + if ev, ok := e.(ToolCallStartEvent); ok { + _, _ = runner.Emit(extensions.ToolCallInputStartEvent{ + ToolCallID: ev.ToolCallID, + ToolName: ev.ToolName, + ToolKind: ev.ToolKind, + }) + } + }) + } + if runner.HasHandlers(extensions.ToolCallInputDelta) { + m.Subscribe(func(e Event) { + if ev, ok := e.(ToolCallDeltaEvent); ok { + _, _ = runner.Emit(extensions.ToolCallInputDeltaEvent{ + ToolCallID: ev.ToolCallID, + Delta: ev.Delta, + }) + } + }) + } + if runner.HasHandlers(extensions.ToolCallInputEnd) { + m.Subscribe(func(e Event) { + if ev, ok := e.(ToolCallEndEvent); ok { + _, _ = runner.Emit(extensions.ToolCallInputEndEvent{ + ToolCallID: ev.ToolCallID, + }) + } + }) + } + if runner.HasHandlers(extensions.AgentEnd) { m.Subscribe(func(e Event) { if ev, ok := e.(TurnEndEvent); ok { diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index f2e19ce6..0e945e7d 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -2020,6 +2020,25 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent. resp := <-responseCh return resp.Password, resp.Cancelled }, + // Tool call argument streaming — fire as the LLM generates tool arguments + func(toolCallID, toolName string) { + m.events.emit(ToolCallStartEvent{ + ToolCallID: toolCallID, + ToolName: toolName, + ToolKind: toolKindFor(toolName), + }) + }, + func(toolCallID, delta string) { + m.events.emit(ToolCallDeltaEvent{ + ToolCallID: toolCallID, + Delta: delta, + }) + }, + func(toolCallID string) { + m.events.emit(ToolCallEndEvent{ + ToolCallID: toolCallID, + }) + }, ) } diff --git a/skills/kit-extensions/SKILL.md b/skills/kit-extensions/SKILL.md index 546096b1..d5bb3ff8 100644 --- a/skills/kit-extensions/SKILL.md +++ b/skills/kit-extensions/SKILL.md @@ -55,7 +55,7 @@ The `Init` function receives an `ext.API` object for registering handlers, and e ## Lifecycle Events -Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`. +Kit provides 21 lifecycle events. Each handler receives an event struct and a `Context`. ### Session Events @@ -136,6 +136,37 @@ api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultRes }) ``` +### Tool Call Input Streaming Events + +These events fire during the LLM's tool argument generation phase, **before** the tool call is fully parsed and before `OnToolCall` fires. They enable UIs to show tool activity immediately rather than waiting for the full argument JSON to finish streaming. + +```go +// Fires when the LLM begins generating tool call arguments. +// The tool name is known but the full argument JSON is still streaming. +api.OnToolCallInputStart(func(e ext.ToolCallInputStartEvent, ctx ext.Context) { + // e.ToolCallID string — stable ID for correlating tool lifecycle events + // e.ToolName string — name of the tool being called + // e.ToolKind string — "execute", "edit", "read", "search", "agent" + ctx.PrintInfo("Tool starting: " + e.ToolName) +}) + +// Fires for each streamed fragment of tool call arguments. +// Useful for live-previewing artifact content or showing a progress indicator. +api.OnToolCallInputDelta(func(e ext.ToolCallInputDeltaEvent, ctx ext.Context) { + // e.ToolCallID string + // e.Delta string — JSON fragment of tool arguments +}) + +// Fires when tool argument streaming is complete, before the tool call +// is parsed and execution begins. Transition UI from "generating args" +// to "executing". +api.OnToolCallInputEnd(func(e ext.ToolCallInputEndEvent, ctx ext.Context) { + // e.ToolCallID string +}) +``` + +**Full tool lifecycle order**: `OnToolCallInputStart` → `OnToolCallInputDelta` (repeated) → `OnToolCallInputEnd` → `OnToolCall` → `OnToolExecutionStart` → `OnToolOutput` (optional, repeated) → `OnToolExecutionEnd` → `OnToolResult` + ### Input Events ```go diff --git a/skills/kit-sdk/SKILL.md b/skills/kit-sdk/SKILL.md index 059ac3fa..f6acb004 100644 --- a/skills/kit-sdk/SKILL.md +++ b/skills/kit-sdk/SKILL.md @@ -252,6 +252,25 @@ unsub := host.OnToolCall(func(e kit.ToolCallEvent) { }) defer unsub() +host.OnToolCallStart(func(e kit.ToolCallStartEvent) { + // Fires when the LLM begins generating tool call arguments. + // e.ToolCallID, e.ToolName, e.ToolKind + // Use this to show a "running" indicator immediately — before the + // full argument JSON finishes streaming (eliminates "dead air"). +}) + +host.OnToolCallDelta(func(e kit.ToolCallDeltaEvent) { + // Fires for each streamed fragment of tool call arguments. + // e.ToolCallID, e.Delta (JSON fragment) + // Useful for live-previewing artifact content or progress indicators. +}) + +host.OnToolCallEnd(func(e kit.ToolCallEndEvent) { + // Fires when tool argument streaming is complete, before execution. + // e.ToolCallID + // Transition UI from "generating args" to "executing". +}) + host.OnToolResult(func(e kit.ToolResultEvent) { // e.ToolCallID, e.ToolName, e.ToolKind, e.ToolArgs, e.ParsedArgs // e.Result, e.IsError, e.Metadata (*ToolResultMetadata) @@ -303,6 +322,9 @@ unsub := host.Subscribe(func(e kit.Event) { | `message_start` | `MessageStartEvent` | *(none)* | | `message_update` | `MessageUpdateEvent` | `Chunk` | | `message_end` | `MessageEndEvent` | `Content` | +| `tool_call_start` | `ToolCallStartEvent` | `ToolCallID`, `ToolName`, `ToolKind` | +| `tool_call_delta` | `ToolCallDeltaEvent` | `ToolCallID`, `Delta` | +| `tool_call_end` | `ToolCallEndEvent` | `ToolCallID` | | `tool_call` | `ToolCallEvent` | `ToolCallID`, `ToolName`, `ToolKind`, `ToolArgs`, `ParsedArgs` | | `tool_execution_start` | `ToolExecutionStartEvent` | `ToolCallID`, `ToolName`, `ToolKind`, `ToolArgs` | | `tool_execution_end` | `ToolExecutionEndEvent` | `ToolCallID`, `ToolName`, `ToolKind` | @@ -316,6 +338,8 @@ unsub := host.Subscribe(func(e kit.Event) { | `steer_consumed` | `SteerConsumedEvent` | `Count` | | `password_prompt` | `PasswordPromptEvent` | `Prompt`, `ResponseCh` | +**Tool call streaming lifecycle**: `ToolCallStartEvent` → `ToolCallDeltaEvent` (repeated) → `ToolCallEndEvent` → `ToolCallEvent` → `ToolExecutionStartEvent` → `ToolOutputEvent` (optional, repeated) → `ToolExecutionEndEvent` → `ToolResultEvent` + **PasswordPromptEvent** (for sudo password handling): ```go // PasswordPromptEvent fires when a sudo command needs a password. diff --git a/www/pages/extensions/capabilities.md b/www/pages/extensions/capabilities.md index 68123077..dd061006 100644 --- a/www/pages/extensions/capabilities.md +++ b/www/pages/extensions/capabilities.md @@ -7,7 +7,7 @@ description: All extension capabilities — lifecycle events, tools, commands, w ## Lifecycle events -Extensions can hook into 23 lifecycle events: +Extensions can hook into 26 lifecycle events: | Event | Description | |-------|-------------| @@ -17,6 +17,9 @@ Extensions can hook into 23 lifecycle events: | `OnAgentStart` | Agent loop started | | `OnAgentEnd` | Agent loop completed | | `OnToolCall` | Tool call requested by the model | +| `OnToolCallInputStart` | LLM began generating tool call arguments (tool name known, args streaming) | +| `OnToolCallInputDelta` | Streamed JSON fragment of tool call arguments | +| `OnToolCallInputEnd` | Tool argument streaming complete, before execution begins | | `OnToolExecutionStart` | Tool execution beginning | | `OnToolOutput` | Streaming tool output chunk (for long-running tools) | | `OnToolExecutionEnd` | Tool execution completed | diff --git a/www/pages/sdk/callbacks.md b/www/pages/sdk/callbacks.md index aebca7a5..87d34815 100644 --- a/www/pages/sdk/callbacks.md +++ b/www/pages/sdk/callbacks.md @@ -41,6 +41,32 @@ unsub6 := host.OnTurnEnd(func(event kit.TurnEndEvent) { defer unsub6() ``` +## Tool call argument streaming + +For tools with large arguments (e.g., `write` with a full file body), the `ToolCallEvent` only fires after the full argument JSON finishes streaming — which can take 5-10+ seconds of "dead air." These three events fire during argument generation so UIs can show activity immediately: + +```go +host.OnToolCallStart(func(event kit.ToolCallStartEvent) { + // Fires as soon as the LLM begins generating tool arguments. + // event.ToolCallID, event.ToolName, event.ToolKind + fmt.Printf("⏳ %s generating arguments...\n", event.ToolName) +}) + +host.OnToolCallDelta(func(event kit.ToolCallDeltaEvent) { + // Each streamed JSON fragment of the tool arguments. + // event.ToolCallID, event.Delta + // Useful for live-previewing content or showing byte progress. +}) + +host.OnToolCallEnd(func(event kit.ToolCallEndEvent) { + // Tool argument streaming complete — execution about to begin. + // event.ToolCallID + fmt.Printf("✓ Arguments ready, executing...\n") +}) +``` + +**Full tool lifecycle**: `ToolCallStartEvent` → `ToolCallDeltaEvent` (repeated) → `ToolCallEndEvent` → `ToolCallEvent` → `ToolExecutionStartEvent` → `ToolOutputEvent` (optional) → `ToolExecutionEndEvent` → `ToolResultEvent` + ## Hook system Hooks can **modify or cancel** operations. Unlike events (read-only), hooks are read-write interceptors. @@ -104,7 +130,10 @@ Lower values run first. First non-nil result wins. | Event | Description | |-------|-------------| -| `ToolCallEvent` | Tool call parsed and about to execute | +| `ToolCallStartEvent` | LLM began generating tool call arguments (tool name known, args streaming) | +| `ToolCallDeltaEvent` | Streamed JSON fragment of tool call arguments | +| `ToolCallEndEvent` | Tool argument streaming complete, before execution begins | +| `ToolCallEvent` | Tool call fully parsed and about to execute | | `ToolResultEvent` | Tool execution completed with result | | `ToolOutputEvent` | Streaming output chunk from tool (e.g., bash stdout/stderr) | | `MessageUpdateEvent` | Streaming text chunk from LLM |