Compare commits

...

5 Commits

Author SHA1 Message Date
Ed Zynda 1cd074836f docs: document subagent monitoring events and extension
Update all documentation to include the new OnSubagentStart, OnSubagentChunk,
OnSubagentEnd lifecycle events for monitoring subagents spawned by the main agent:

README.md:
- Update lifecycle events list (20 → 23 events)
- Add subagent-monitor.go to examples list

www/pages/extensions/capabilities.md:
- Update event count (20 → 23)
- Add 3 new subagent events to lifecycle table
- Add 'Monitoring subagents spawned by the main agent' section with
  complete event handler documentation and struct definitions

www/pages/extensions/examples.md:
- Add subagent-monitor.go to Multi-agent section
- Add subagent-monitor_test.go to Development section

www/pages/advanced/subagents.md:
- Add 'Monitoring subagents from extensions' section with complete
  code example and event struct documentation
- Cross-reference subagent-monitor.go example

.agents/skills/kit-extensions/SKILL.md:
- Update lifecycle event count (18 → 21)
- Add Subagent Events section with full handler documentation
- Add event struct definitions (SubagentStartEvent, SubagentChunkEvent,
  SubagentEndEvent)
- Add 'Pattern: Monitoring Subagents with Widgets' complete example
  with Yaegi-safe design notes
2026-03-26 13:41:43 +03:00
Ed Zynda ab3ce260c8 feat: add subagent monitoring extension with horizontal widget layout
Add new extension API hooks for tracking spawned subagents:
- OnSubagentStart, OnSubagentChunk, OnSubagentEnd events
- Extensions bridge for forwarding child subagent events

Create subagent-monitor.go extension:
- Displays horizontally-stacked widgets above input box
- Shows real-time scrolling output from each subagent
- Yaegi-safe: no sync.Mutex, no goroutines, nil-guarded context calls
- Race-free design with on-demand elapsed time calculation

Add comprehensive tests:
- SessionStart, SubagentLifecycle, MultipleSubagents, SessionShutdown

Update symbols.go to export new event types for Yaegi interpreter.
2026-03-26 13:38:06 +03:00
Ed Zynda 8e8cc3946d fix: render steering user message immediately on mid-turn SteerConsumedEvent
When a steer message is consumed mid-turn via PrepareStep, no new
SpinnerEvent{Show: true} fires within that turn, so the message was
stuck in pendingUserPrints indefinitely and never rendered.

Branch the SteerConsumedEvent handler on m.state:
- stateWorking (mid-turn): flush live stream content, then print the
  steering user messages to scrollback immediately via drainScrollback.
- idle/post-turn: keep the existing pendingUserPrints deferral so the
  SpinnerEvent{Show: true} for the next turn orders things correctly.
2026-03-26 12:51:44 +03:00
Ed Zynda e18e36625e fix: route opencode models through correct provider API
Models from the opencode provider (like claude-opus-4-6 and gpt-5.3-codex)
have provider overrides in the models database that specify different npm
packages than the provider's default. The code was ignoring these overrides
and routing all models through openaicompat, causing "bad request" errors.

Changes:
- Added Provider field to modelsDBModel to capture model-specific overrides
- Added ProviderNPM field to ModelInfo registry struct
- Updated autoRouteProvider() to check for model-specific provider overrides
- Fixed URL path handling for anthropic provider (strip /v1 suffix to avoid
  double /v1/v1 paths when using third-party anthropic-compatible APIs)

Fixes routing for:
- opencode/claude-opus-4-6 -> @ai-sdk/anthropic
- opencode/gpt-5.3-codex -> @ai-sdk/openai
2026-03-26 12:44:19 +03:00
Ed Zynda be55bc03f1 Add mid-turn steering with Ctrl+S 2026-03-26 12:10:14 +03:00
26 changed files with 1339 additions and 46 deletions
+304
View File
@@ -0,0 +1,304 @@
//go:build ignore
// subagent-monitor — live horizontal widget strip for spawned subagents
//
// Subscribes to subagents spawned by the main Kit agent and displays a
// single widget just above the input box. Each subagent occupies one column
// in a side-by-side horizontal layout. Columns show scrolling real-time
// output as the subagent works. When a subagent finishes its column is
// removed automatically.
//
// Yaegi-safe design notes:
// - No sync.Mutex (Yaegi has reflection issues with sync primitives)
// - No channels in maps (Yaegi panics on range over map[string]chan)
// - All ctx.* calls guarded with nil checks
// - Simple data structures only
package main
import (
"fmt"
"strings"
"time"
"kit/ext"
)
// ---------------------------------------------------------------------------
// Per-subagent state
// ---------------------------------------------------------------------------
type submonEntry struct {
id int
callID string
task string
lines []string
started time.Time
elapsed time.Duration
}
const (
submonColWidth = 34 // visible character width per column
submonMaxLines = 5 // scrolling output lines per column
submonColGap = 2 // spaces between columns
)
// ---------------------------------------------------------------------------
// Package-level state - all simple types
// ---------------------------------------------------------------------------
var (
submonCtx ext.Context
submonHasCtx bool
submonEntries []*submonEntry
submonNextID int
)
func submonInit() {
submonEntries = nil
submonNextID = 1
}
// ---------------------------------------------------------------------------
// String helpers
// ---------------------------------------------------------------------------
func submonPad(s string, w int) string {
r := []rune(s)
if len(r) >= w {
return string(r[:w])
}
return s + strings.Repeat(" ", w-len(r))
}
func submonTrunc(s string, w int) string {
r := []rune(s)
if len(r) <= w {
return s
}
if w <= 1 {
return "…"
}
return string(r[:w-1]) + "…"
}
// ---------------------------------------------------------------------------
// Widget rendering
// ---------------------------------------------------------------------------
func submonRenderColumn(e *submonEntry) []string {
var rows []string
// Calculate elapsed time on-demand to avoid race conditions with ticker
elapsed := e.elapsed
if elapsed == 0 && !e.started.IsZero() {
elapsed = time.Since(e.started)
}
secs := int(elapsed.Seconds())
timeStr := fmt.Sprintf("%ds", secs)
taskMax := submonColWidth - len(timeStr) - 3
taskPart := submonTrunc(e.task, taskMax)
header := fmt.Sprintf("#%d %s %s", e.id, taskPart, timeStr)
rows = append(rows, submonPad(header, submonColWidth))
display := e.lines
if len(display) > submonMaxLines {
display = display[len(display)-submonMaxLines:]
}
for _, l := range display {
rows = append(rows, submonPad(" "+submonTrunc(l, submonColWidth-2), submonColWidth))
}
for len(rows) < submonMaxLines+1 {
if len(rows) == 1 && len(e.lines) == 0 {
rows = append(rows, submonPad(" waiting…", submonColWidth))
} else {
rows = append(rows, strings.Repeat(" ", submonColWidth))
}
}
return rows
}
func submonBuildWidget() string {
if len(submonEntries) == 0 {
return ""
}
numCols := len(submonEntries)
numRows := submonMaxLines + 1
cols := make([][]string, numCols)
for i, e := range submonEntries {
rows := submonRenderColumn(e)
col := make([]string, numRows)
for j := 0; j < numRows; j++ {
if j < len(rows) {
col[j] = rows[j]
} else {
col[j] = strings.Repeat(" ", submonColWidth)
}
}
cols[i] = col
}
gap := strings.Repeat(" ", submonColGap)
var sb strings.Builder
for row := 0; row < numRows; row++ {
for ci := range cols {
if ci > 0 {
sb.WriteString(gap)
}
sb.WriteString(cols[ci][row])
}
if row < numRows-1 {
sb.WriteString("\n")
}
}
return sb.String()
}
func submonPushWidget() {
if !submonHasCtx {
return
}
if submonCtx.SetWidget == nil {
return
}
text := submonBuildWidget()
if len(submonEntries) == 0 {
if submonCtx.RemoveWidget != nil {
submonCtx.RemoveWidget("submon")
}
return
}
submonCtx.SetWidget(ext.WidgetConfig{
ID: "submon",
Placement: ext.WidgetAbove,
Content: ext.WidgetContent{Text: text},
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
Priority: 0,
})
}
func submonAppendLine(e *submonEntry, line string) {
line = strings.TrimRight(line, "\r\n")
if strings.TrimSpace(line) == "" {
return
}
e.lines = append(e.lines, line)
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
func Init(api ext.API) {
submonInit()
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
submonInit()
if ctx.RemoveWidget != nil {
ctx.RemoveWidget("submon")
}
})
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
})
// ── SubagentStart ────────────────────────────────────────────────────────
api.OnSubagentStart(func(e ext.SubagentStartEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
id := submonNextID
submonNextID++
entry := &submonEntry{
id: id,
callID: e.ToolCallID,
task: e.Task,
started: time.Now(),
}
submonEntries = append(submonEntries, entry)
submonPushWidget()
})
// ── SubagentChunk ────────────────────────────────────────────────────────
api.OnSubagentChunk(func(e ext.SubagentChunkEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
var entry *submonEntry
for _, en := range submonEntries {
if en.callID == e.ToolCallID {
entry = en
break
}
}
if entry == nil {
return
}
switch e.ChunkType {
case "text":
for _, line := range strings.Split(e.Content, "\n") {
submonAppendLine(entry, line)
}
case "tool_call":
submonAppendLine(entry, "→ "+e.ToolName)
case "tool_execution_start":
submonAppendLine(entry, "⚙ "+e.ToolName)
case "tool_result":
if e.IsError {
submonAppendLine(entry, "✗ "+e.ToolName)
} else {
submonAppendLine(entry, "✓ "+e.ToolName)
}
}
submonPushWidget()
})
// ── SubagentEnd ──────────────────────────────────────────────────────────
api.OnSubagentEnd(func(e ext.SubagentEndEvent, ctx ext.Context) {
submonCtx = ctx
submonHasCtx = true
var entry *submonEntry
for _, en := range submonEntries {
if en.callID == e.ToolCallID {
entry = en
break
}
}
if entry != nil {
entry.elapsed = time.Since(entry.started)
if e.ErrorMsg != "" {
submonAppendLine(entry, "✗ "+submonTrunc(e.ErrorMsg, submonColWidth-2))
}
}
submonPushWidget()
// Remove the entry immediately (no goroutine to avoid races)
newEntries := submonEntries[:0]
for _, en := range submonEntries {
if en.callID != e.ToolCallID {
newEntries = append(newEntries, en)
}
}
submonEntries = newEntries
submonPushWidget()
})
// ── SessionShutdown ──────────────────────────────────────────────────────
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
submonInit()
// Guard ctx access - may be nil during shutdown
if ctx.RemoveWidget != nil {
ctx.RemoveWidget("submon")
}
})
}
+2 -1
View File
@@ -287,7 +287,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
**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
**Custom Components**:
- **Tools**: Add new tools the LLM can invoke
@@ -335,6 +335,7 @@ See the `examples/extensions/` directory:
- `protected-paths.go` - Path protection for sensitive files
- `subagent-widget.go` - Multi-agent orchestration with status widget
- `subagent-test.go` - Subagent testing utilities
- `subagent-monitor.go` - Real-time monitoring widget for spawned subagents
- `summarize.go` - Conversation summarization
- `tool-logger.go` - Log all tool calls
- `neon-theme.go` - Custom theme registration and switching
+1 -1
View File
@@ -811,7 +811,7 @@ func runNormalMode(ctx context.Context) error {
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
PrintBlock: appInstance.PrintBlockFromExtension,
SendMessage: func(text string) { appInstance.Run(text) },
CancelAndSend: func(text string) { appInstance.Steer(text) },
CancelAndSend: func(text string) { appInstance.InterruptAndSend(text) },
Exit: func() { appInstance.QuitFromExtension() },
SetWidget: func(config extensions.WidgetConfig) {
kitInstance.SetExtensionWidget(config)
@@ -0,0 +1,159 @@
package main
import (
"fmt"
"testing"
"time"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// TestSubagentMonitor_SessionStart verifies OnSessionStart initializes state
// without panicking and properly guards nil ctx calls.
func TestSubagentMonitor_SessionStart(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
// Emit SessionStart - should not panic even with nil ctx functions
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
}
// TestSubagentMonitor_SubagentLifecycle verifies the full subagent lifecycle
// creates entries and emits widget updates.
func TestSubagentMonitor_SubagentLifecycle(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
// Start session
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
// Emit SubagentStart
_, err = harness.Emit(extensions.SubagentStartEvent{
ToolCallID: "call-1",
Task: "test task",
})
if err != nil {
t.Fatalf("SubagentStart should not error: %v", err)
}
// Emit a few chunks
for i := range 3 {
_, err = harness.Emit(extensions.SubagentChunkEvent{
ToolCallID: "call-1",
Task: "test task",
ChunkType: "text",
Content: fmt.Sprintf("line %d", i),
})
if err != nil {
t.Fatalf("SubagentChunk %d should not error: %v", i, err)
}
}
// Emit tool call chunk
_, err = harness.Emit(extensions.SubagentChunkEvent{
ToolCallID: "call-1",
Task: "test task",
ChunkType: "tool_call",
ToolName: "bash",
})
if err != nil {
t.Fatalf("SubagentChunk tool_call should not error: %v", err)
}
// Emit SubagentEnd
_, err = harness.Emit(extensions.SubagentEndEvent{
ToolCallID: "call-1",
Task: "test task",
Response: "done",
})
if err != nil {
t.Fatalf("SubagentEnd should not error: %v", err)
}
// Give time for cleanup goroutine
time.Sleep(100 * time.Millisecond)
}
// TestSubagentMonitor_MultipleSubagents verifies multiple parallel subagents.
func TestSubagentMonitor_MultipleSubagents(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
// Start 3 subagents
for i := 1; i <= 3; i++ {
_, err := harness.Emit(extensions.SubagentStartEvent{
ToolCallID: fmt.Sprintf("call-%d", i),
Task: fmt.Sprintf("task %d", i),
})
if err != nil {
t.Fatalf("SubagentStart %d should not error: %v", i, err)
}
}
// Emit chunks for each
for i := 1; i <= 3; i++ {
_, err := harness.Emit(extensions.SubagentChunkEvent{
ToolCallID: fmt.Sprintf("call-%d", i),
Task: fmt.Sprintf("task %d", i),
ChunkType: "text",
Content: fmt.Sprintf("output from agent %d", i),
})
if err != nil {
t.Fatalf("SubagentChunk %d should not error: %v", i, err)
}
}
// End all subagents
for i := 1; i <= 3; i++ {
_, err := harness.Emit(extensions.SubagentEndEvent{
ToolCallID: fmt.Sprintf("call-%d", i),
Task: fmt.Sprintf("task %d", i),
Response: "completed",
})
if err != nil {
t.Fatalf("SubagentEnd %d should not error: %v", i, err)
}
}
time.Sleep(100 * time.Millisecond)
}
// TestSubagentMonitor_SessionShutdown verifies shutdown doesn't panic
// even with nil ctx functions.
func TestSubagentMonitor_SessionShutdown(t *testing.T) {
harness := test.New(t)
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
// Start then shutdown
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
if err != nil {
t.Fatalf("SessionStart should not error: %v", err)
}
// Start a subagent
_, err = harness.Emit(extensions.SubagentStartEvent{
ToolCallID: "call-1",
Task: "test task",
})
if err != nil {
t.Fatalf("SubagentStart should not error: %v", err)
}
// Shutdown - should not panic even with active subagent
_, err = harness.Emit(extensions.SessionShutdownEvent{})
if err != nil {
t.Fatalf("SessionShutdown should not error: %v", err)
}
}
+46 -2
View File
@@ -275,7 +275,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
var completedStepMessages []fantasy.Message
// Use fantasy's streaming agent
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
streamCall := fantasy.AgentStreamCall{
Prompt: prompt,
Files: files,
Messages: history,
@@ -364,7 +364,51 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
}
return nil
},
})
}
// If a steer channel is attached to the context, wire up a
// PrepareStep function that drains the channel between steps
// and injects pending steer messages as user messages before
// the next LLM call. This enables graceful mid-turn steering
// without cancelling in-progress tool execution.
if steerCh := steerChFromContext(ctx); steerCh != nil {
onConsumed := steerConsumedFromContext(ctx)
streamCall.PrepareStep = func(
stepCtx context.Context,
opts fantasy.PrepareStepFunctionOptions,
) (context.Context, fantasy.PrepareStepResult, error) {
// Drain all pending steer messages (non-blocking).
var steered []string
for {
select {
case msg := <-steerCh:
steered = append(steered, msg)
default:
goto done
}
}
done:
result := fantasy.PrepareStepResult{
Model: opts.Model,
Messages: opts.Messages,
}
if len(steered) > 0 {
// Inject each steer message as a user message so the
// LLM sees the redirection on the next step.
for _, text := range steered {
result.Messages = append(result.Messages,
fantasy.NewUserMessage(text))
}
// Notify that steer messages were consumed.
if onConsumed != nil {
onConsumed(len(steered))
}
}
return stepCtx, result, nil
}
}
result, err := a.fantasyAgent.Stream(ctx, streamCall)
if err != nil {
// On cancellation (or any error), return a partial result
// containing messages from completed steps so the caller can
+35
View File
@@ -0,0 +1,35 @@
package agent
import "context"
// steerChKey is the context key for the steer channel.
type steerChKey struct{}
// steerConsumedKey is the context key for the steer-consumed callback.
type steerConsumedKey struct{}
// ContextWithSteerCh returns a new context with the steer channel attached.
// The agent's PrepareStep function checks this channel between steps and
// injects any pending steer messages as user messages before the next LLM call.
func ContextWithSteerCh(ctx context.Context, ch <-chan string) context.Context {
return context.WithValue(ctx, steerChKey{}, ch)
}
// ContextWithSteerConsumed returns a new context with a callback that fires
// when steer messages are consumed by PrepareStep. The count argument is the
// number of messages injected in this batch.
func ContextWithSteerConsumed(ctx context.Context, fn func(count int)) context.Context {
return context.WithValue(ctx, steerConsumedKey{}, fn)
}
// steerChFromContext extracts the steer channel from the context, or nil.
func steerChFromContext(ctx context.Context) <-chan string {
ch, _ := ctx.Value(steerChKey{}).(<-chan string)
return ch
}
// steerConsumedFromContext extracts the steer-consumed callback, or nil.
func steerConsumedFromContext(ctx context.Context) func(int) {
fn, _ := ctx.Value(steerConsumedKey{}).(func(int))
return fn
}
+71 -5
View File
@@ -159,11 +159,57 @@ func (a *App) QueueLength() int {
return len(a.queue)
}
// Steer cancels the current agent step (if running), clears the queue, and
// sends a new message that will execute as soon as the current step finishes
// cancelling. If the agent is idle, the message executes immediately.
// This is the "steer" delivery mode for SendMessage.
func (a *App) Steer(prompt string) {
// Steer injects a steering message into the currently running agent turn.
// If the agent is in a multi-step tool loop, the message is delivered after
// the current tool execution finishes but before the next LLM call (graceful
// mid-turn injection via Fantasy's PrepareStep). If the agent is streaming
// a text-only response (no pending tool calls), the message waits until the
// response completes and then executes as the next turn.
//
// If the agent is idle, the message starts executing immediately (same as Run).
//
// Returns the number of pending steer/queue items (0 = started immediately,
// >0 = injected/queued). The caller must update UI state based on the return
// value — Steer does NOT send events to the program to avoid deadlocking
// when called from within Update().
//
// Satisfies ui.AppController.
func (a *App) Steer(prompt string) int {
a.mu.Lock()
if a.closed {
a.mu.Unlock()
return 0
}
if !a.busy {
// Not busy — start immediately, same as Run().
item := queueItem{Prompt: prompt}
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(item)
return 0
}
a.mu.Unlock()
// Agent is busy — inject via the SDK's steer channel. The message
// will be picked up by PrepareStep between agent steps (after tool
// execution, before next LLM call). If PrepareStep doesn't fire
// (text-only response), drainQueue will pick it up after the turn.
if a.opts.Kit != nil {
a.opts.Kit.InjectSteer(prompt)
}
return 1
}
// InterruptAndSend cancels the current agent step (if running), clears the
// queue, and sends a new message that will execute as soon as the current
// step finishes cancelling. If the agent is idle, the message executes
// immediately. This is the hard-cancel delivery mode used by extensions'
// CancelAndSend.
func (a *App) InterruptAndSend(prompt string) {
a.mu.Lock()
if a.closed {
@@ -434,6 +480,24 @@ func (a *App) drainQueue(first queueItem) {
// Process all collected items as a single batch
a.runQueueBatch(items)
// Drain any unconsumed steer messages from the SDK channel.
// These arrive when the user steered during a text-only response
// (no tool calls, so PrepareStep didn't fire for a second step).
// They go to the front of the queue so they run next.
if a.opts.Kit != nil {
if leftover := a.opts.Kit.DrainSteer(); len(leftover) > 0 {
a.mu.Lock()
steerItems := make([]queueItem, len(leftover))
for i, text := range leftover {
steerItems[i] = queueItem{Prompt: text}
}
a.queue = append(steerItems, a.queue...)
a.mu.Unlock()
// Notify UI about the consumed steer messages.
a.sendEvent(SteerConsumedEvent{})
}
}
// Check if more items were queued while we were processing
a.mu.Lock()
hasMore := len(a.queue) > 0
@@ -687,6 +751,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
int(ev.CacheWriteTokens),
)
}
case kit.SteerConsumedEvent:
sendFn(SteerConsumedEvent{})
}
}))
+6
View File
@@ -141,6 +141,12 @@ type CompactErrorEvent struct {
Err error
}
// SteerConsumedEvent is sent when one or more steering messages have been
// consumed — either injected mid-turn via PrepareStep, or drained into the
// queue after a turn completes. The TUI uses this to clear the steering
// badge from the display.
type SteerConsumedEvent struct{}
// ModelChangedEvent is sent when an extension changes the active model via
// ctx.SetModel. The TUI updates the model name shown in the status bar and
// message attribution.
+81 -1
View File
@@ -750,6 +750,9 @@ type API struct {
registerOption func(OptionDef)
registerShortcutFn func(ShortcutDef, func(Context))
registerMessageRendererFn func(MessageRendererConfig)
onSubagentStart func(func(SubagentStartEvent, Context))
onSubagentChunk func(func(SubagentChunkEvent, Context))
onSubagentEnd func(func(SubagentEndEvent, Context))
}
// OnToolCall registers a handler that fires before a tool executes.
@@ -781,6 +784,27 @@ func (a *API) OnToolResult(handler func(ToolResultEvent, Context) *ToolResultRes
a.onToolResult(handler)
}
// OnSubagentStart registers a handler that fires when a spawn_subagent tool
// call begins executing. Use the ToolCallID to correlate with subsequent
// OnSubagentChunk and OnSubagentEnd events for the same subagent.
func (a *API) OnSubagentStart(handler func(SubagentStartEvent, Context)) {
a.onSubagentStart(handler)
}
// OnSubagentChunk registers a handler for real-time events from a running
// subagent. ChunkType identifies the kind of event ("text", "tool_call",
// "tool_result", "tool_execution_start", "tool_execution_end", etc.).
// Correlate with OnSubagentStart via the ToolCallID field.
func (a *API) OnSubagentChunk(handler func(SubagentChunkEvent, Context)) {
a.onSubagentChunk(handler)
}
// OnSubagentEnd registers a handler that fires when a spawn_subagent call
// completes. ErrorMsg is non-empty when the subagent failed.
func (a *API) OnSubagentEnd(handler func(SubagentEndEvent, Context)) {
a.onSubagentEnd(handler)
}
// OnInput registers a handler that fires when user input is received.
// Return a non-nil InputResult to transform or handle the input.
func (a *API) OnInput(handler func(InputEvent, Context) *InputResult) {
@@ -1781,9 +1805,65 @@ type BeforeCompactResult struct {
func (BeforeCompactResult) isResult() {}
// ---------------------------------------------------------------------------
// Theme types (exposed to Yaegi — concrete structs, string hex colors)
// Subagent lifecycle events (exposed to Yaegi — concrete structs)
// ---------------------------------------------------------------------------
// SubagentStartEvent fires when a spawn_subagent tool call begins executing.
type SubagentStartEvent struct {
// ToolCallID is the LLM-assigned ID of the spawn_subagent tool call.
// Use this to correlate SubagentChunkEvent and SubagentEndEvent.
ToolCallID string
// Task is the task description passed to the subagent.
Task string
}
func (e SubagentStartEvent) Type() EventType { return SubagentStart }
// SubagentChunkEvent fires for each real-time event from a running subagent.
// Type field indicates the kind of event; read the relevant fields accordingly.
type SubagentChunkEvent struct {
// ToolCallID matches the SubagentStartEvent.ToolCallID for this subagent.
ToolCallID string
// Task is the task description (repeated for convenience).
Task string
// ChunkType identifies the event kind:
// "text" — LLM text chunk (read Content)
// "reasoning" — reasoning/thinking delta (read Content)
// "tool_call" — subagent called a tool (read ToolName, ToolArgs)
// "tool_result" — tool returned a result (read ToolName, ToolResult, IsError)
// "tool_execution_start" — tool began executing (read ToolName)
// "tool_execution_end" — tool finished executing (read ToolName)
// "turn_start" — subagent turn began
// "turn_end" — subagent turn ended
ChunkType string
// Content carries text for "text" and "reasoning" chunk types.
Content string
// ToolName is set on tool-related chunk types.
ToolName string
// ToolArgs is the JSON-encoded tool arguments for "tool_call" chunks.
ToolArgs string
// ToolResult is the tool output for "tool_result" chunks.
ToolResult string
// IsError is true when a "tool_result" chunk represents an error.
IsError bool
}
func (e SubagentChunkEvent) Type() EventType { return SubagentChunk }
// SubagentEndEvent fires when a spawn_subagent tool call completes.
type SubagentEndEvent struct {
// ToolCallID matches the SubagentStartEvent.ToolCallID for this subagent.
ToolCallID string
// Task is the task description.
Task string
// Response is the subagent's final text response (empty on error).
Response string
// ErrorMsg is non-empty when the subagent failed.
ErrorMsg string
}
func (e SubagentEndEvent) Type() EventType { return SubagentEnd }
// ThemeColor is an adaptive color pair with light and dark hex values.
// Either field may be empty to inherit from the default theme.
type ThemeColor struct {
+13
View File
@@ -71,6 +71,18 @@ const (
// BeforeCompact fires before context compaction runs. Handlers can
// cancel compaction by returning Cancel=true.
BeforeCompact EventType = "before_compact"
// SubagentStart fires when a spawn_subagent tool call begins executing.
// Carries the tool call ID and the task description.
SubagentStart EventType = "subagent_start"
// SubagentChunk fires for each real-time event emitted by a running
// subagent: text chunks, tool calls, tool results, etc.
SubagentChunk EventType = "subagent_chunk"
// SubagentEnd fires when a spawn_subagent tool call completes (success
// or error). Carries the final response and any error message.
SubagentEnd EventType = "subagent_end"
)
// AllEventTypes returns every supported event type.
@@ -82,6 +94,7 @@ func AllEventTypes() []EventType {
SessionStart, SessionShutdown,
ModelChange, ContextPrepare,
BeforeFork, BeforeSessionSwitch, BeforeCompact,
SubagentStart, SubagentChunk, SubagentEnd,
}
}
+5 -2
View File
@@ -4,8 +4,8 @@ import "testing"
func TestAllEventTypes_Count(t *testing.T) {
all := AllEventTypes()
if len(all) != 18 {
t.Fatalf("expected 18 event types, got %d", len(all))
if len(all) != 21 {
t.Fatalf("expected 21 event types, got %d", len(all))
}
}
@@ -55,6 +55,9 @@ func TestEventType_TypeMethod(t *testing.T) {
{BeforeForkEvent{TargetID: "abc"}, BeforeFork},
{BeforeSessionSwitchEvent{Reason: "new"}, BeforeSessionSwitch},
{BeforeCompactEvent{EstimatedTokens: 1000}, BeforeCompact},
{SubagentStartEvent{ToolCallID: "x", Task: "t"}, SubagentStart},
{SubagentChunkEvent{ToolCallID: "x", ChunkType: "text"}, SubagentChunk},
{SubagentEndEvent{ToolCallID: "x"}, SubagentEnd},
}
for _, tt := range tests {
+18
View File
@@ -580,6 +580,24 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
},
onSubagentStart: func(h func(SubagentStartEvent, Context)) {
reg(SubagentStart, func(e Event, c Context) Result {
h(e.(SubagentStartEvent), c)
return nil
})
},
onSubagentChunk: func(h func(SubagentChunkEvent, Context)) {
reg(SubagentChunk, func(e Event, c Context) Result {
h(e.(SubagentChunkEvent), c)
return nil
})
},
onSubagentEnd: func(h func(SubagentEndEvent, Context)) {
reg(SubagentEnd, func(e Event, c Context) Result {
h(e.(SubagentEndEvent), c)
return nil
})
},
}
// Call Init — the extension registers its handlers, tools, commands.
+5
View File
@@ -119,6 +119,11 @@ func Symbols() interp.Exports {
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
"SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)),
// Subagent lifecycle events
"SubagentStartEvent": reflect.ValueOf((*SubagentStartEvent)(nil)),
"SubagentChunkEvent": reflect.ValueOf((*SubagentChunkEvent)(nil)),
"SubagentEndEvent": reflect.ValueOf((*SubagentEndEvent)(nil)),
// Theme types
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
+18
View File
@@ -171,5 +171,23 @@ func NewTestAPI(ext *LoadedExtension) API {
registerMessageRendererFn: func(config MessageRendererConfig) {
ext.MessageRenderers = append(ext.MessageRenderers, config)
},
onSubagentStart: func(h func(SubagentStartEvent, Context)) {
reg(SubagentStart, func(e Event, c Context) Result {
h(e.(SubagentStartEvent), c)
return nil
})
},
onSubagentChunk: func(h func(SubagentChunkEvent, Context)) {
reg(SubagentChunk, func(e Event, c Context) Result {
h(e.(SubagentChunkEvent), c)
return nil
})
},
onSubagentEnd: func(h func(SubagentEndEvent, Context)) {
reg(SubagentEnd, func(e Event, c Context) Result {
h(e.(SubagentEndEvent), c)
return nil
})
},
}
}
+15 -9
View File
@@ -17,15 +17,21 @@ type modelsDBProvider struct {
// modelsDBModel represents a model entry from models.dev/api.json.
type modelsDBModel struct {
ID string `json:"id"`
Name string `json:"name"`
Family string `json:"family,omitempty"`
Attachment bool `json:"attachment"`
Reasoning bool `json:"reasoning"`
ToolCall bool `json:"tool_call"`
Temperature bool `json:"temperature"`
Cost modelsDBCost `json:"cost"`
Limit modelsDBLimit `json:"limit"`
ID string `json:"id"`
Name string `json:"name"`
Family string `json:"family,omitempty"`
Attachment bool `json:"attachment"`
Reasoning bool `json:"reasoning"`
ToolCall bool `json:"tool_call"`
Temperature bool `json:"temperature"`
Cost modelsDBCost `json:"cost"`
Limit modelsDBLimit `json:"limit"`
Provider *modelsDBModelProvider `json:"provider,omitempty"` // Model-specific provider override
}
// modelsDBModelProvider represents a provider reference within a model.
type modelsDBModelProvider struct {
NPM string `json:"npm"`
}
// modelsDBCost represents model pricing from models.dev.
+14 -3
View File
@@ -263,14 +263,22 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
// autoRouteProvider attempts to create a provider by looking up its npm package
// in the models.dev database and routing through the appropriate fantasy provider.
// For openai-compatible providers, it uses the api URL from models.dev.
// Models may have a provider override that specifies a different npm package than
// the provider's default (e.g., opencode's claude-opus-4-6 uses @ai-sdk/anthropic).
func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, modelName string, registry *ModelsRegistry) (*ProviderResult, error) {
providerInfo := registry.GetProviderInfo(provider)
if providerInfo == nil {
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", provider)
}
// Check for model-specific provider override
npmPackage := providerInfo.NPM
if modelInfo := registry.LookupModel(provider, modelName); modelInfo != nil && modelInfo.ProviderNPM != "" {
npmPackage = modelInfo.ProviderNPM
}
// Determine the fantasy provider for this npm package
fantasyProvider := npmToFantasyProvider[providerInfo.NPM]
fantasyProvider := npmToFantasyProvider[npmPackage]
if fantasyProvider == "" && providerInfo.API != "" {
// Unknown npm but has API URL → route through openaicompat
fantasyProvider = "openaicompat"
@@ -290,7 +298,7 @@ func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, mo
}
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no fantasy mapping)", provider, providerInfo.NPM)
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no fantasy mapping)", provider, npmPackage)
}
}
@@ -348,7 +356,10 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
opts = append(opts, anthropic.WithAPIKey(apiKey))
if config.ProviderURL != "" {
opts = append(opts, anthropic.WithBaseURL(config.ProviderURL))
// The anthropic client appends "/v1/messages" to the base URL.
// If the provider URL ends with "/v1", strip it to avoid double "/v1/v1" paths.
baseURL := strings.TrimSuffix(config.ProviderURL, "/v1")
opts = append(opts, anthropic.WithBaseURL(baseURL))
}
if config.TLSSkipVerify {
+6
View File
@@ -22,6 +22,7 @@ type ModelInfo struct {
Temperature bool
Cost Cost
Limit Limit
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
}
// Cost represents the pricing information for a model.
@@ -78,6 +79,10 @@ func buildFromModelsDB() map[string]ProviderInfo {
for providerID, dp := range dbProviders {
modelsMap := make(map[string]ModelInfo, len(dp.Models))
for modelID, dm := range dp.Models {
providerNPM := ""
if dm.Provider != nil {
providerNPM = dm.Provider.NPM
}
modelsMap[modelID] = ModelInfo{
ID: dm.ID,
Name: dm.Name,
@@ -94,6 +99,7 @@ func buildFromModelsDB() map[string]ProviderInfo {
Context: dm.Limit.Context,
Output: dm.Limit.Output,
},
ProviderNPM: providerNPM,
}
}
+14 -1
View File
@@ -65,6 +65,10 @@ type InputComponent struct {
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
hideHint bool
// agentBusy indicates the agent is currently working. When true, the
// hint text shows steering shortcut (Ctrl+S) instead of submit.
agentBusy bool
// pendingImages holds clipboard images attached to the next submission.
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
pendingImages []ImageAttachment
@@ -514,7 +518,16 @@ func (s *InputComponent) View() tea.View {
// Adapt hint text to available width (accounting for left padding of 3).
var hint string
availableHintWidth := s.width - 3
if availableHintWidth >= 67 {
if s.agentBusy {
// When the agent is working, show steering shortcut.
if availableHintWidth >= 55 {
hint = "enter queue • ctrl+s steer • esc esc cancel"
} else if availableHintWidth >= 35 {
hint = "↵ queue • ^S steer • esc×2 cancel"
} else {
hint = "^S steer"
}
} else if availableHintWidth >= 67 {
hint = "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
} else if availableHintWidth >= 40 {
hint = "↵ submit • ctrl+j newline • ctrl+v image"
+136 -20
View File
@@ -98,6 +98,12 @@ type AppController interface {
// alongside the text. Returns the current queue depth (0 = started
// immediately, >0 = queued).
RunWithFiles(prompt string, files []fantasy.FilePart) int
// Steer injects a steering message into the currently running agent
// turn. If the agent is busy, the message is delivered between steps
// (after current tool finishes, before next LLM call). If idle, the
// message starts executing immediately. Returns 0 if started
// immediately, >0 if injected/pending.
Steer(prompt string) int
}
// SkillItem holds display metadata about a loaded skill for the startup
@@ -415,6 +421,11 @@ type AppModel struct {
// the input and move to scrollback when the agent picks them up.
queuedMessages []string
// steeringMessages stores the text of prompts that were sent as steer
// messages (injected mid-turn via Ctrl+S). Rendered with a "STEERING"
// badge above the input. Cleared when the steer is consumed.
steeringMessages []string
// pendingUserPrints holds user messages that have been consumed from the
// queue but not yet printed to scrollback. They are deferred until
// SpinnerEvent{Show: true} so the previous assistant response can be
@@ -1070,6 +1081,45 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// In other states pass ESC through to children below.
case "ctrl+s":
// Steer: inject the current input as a steering message into the
// running agent turn. Only active during stateWorking — in input
// state, Ctrl+S is passed through to children (no-op by default).
if m.state == stateWorking && m.appCtrl != nil {
var text string
if ic, ok := m.input.(*InputComponent); ok {
text = strings.TrimSpace(ic.textarea.Value())
}
if text != "" {
// Clear the input and push to history.
if ic, ok := m.input.(*InputComponent); ok {
ic.pushHistory(text)
ic.textarea.SetValue("")
}
// Preprocess @file references.
processedText := text
if m.cwd != "" {
processedText = ProcessFileAttachments(text, m.cwd)
}
// Inject the steer message.
sLen := m.appCtrl.Steer(processedText)
if sLen > 0 {
m.steeringMessages = append(m.steeringMessages, text)
m.distributeHeight()
} else {
// Started immediately (agent was idle).
m.pendingUserPrints = append(m.pendingUserPrints, text)
m.flushStreamAndPendingUserMessages()
if m.state != stateWorking {
m.state = stateWorking
}
}
}
return m, tea.Batch(cmds...)
}
}
// Route key events to the focused child. Check for editor
@@ -1389,6 +1439,38 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.distributeHeight()
case app.SteerConsumedEvent:
// Steering messages were consumed — either injected mid-turn via
// PrepareStep, or drained into the queue after a text-only turn.
//
// Two cases:
//
// 1. Mid-turn (stateWorking, PrepareStep fired): no SpinnerEvent{Show:
// true} will follow within this turn, so we cannot rely on
// flushStreamAndPendingUserMessages() being called. Flush any live
// stream content first (assistant text up to the steer point), then
// render the steering user messages immediately to scrollback.
//
// 2. Post-turn (text-only response, drained after StepComplete): a
// SpinnerEvent{Show: true} for the next turn is already in flight.
// Defer to pendingUserPrints so the previous assistant response is
// flushed first, preserving chronological order.
if m.state == stateWorking {
// Case 1: mid-turn — flush + print immediately.
m.flushStreamContent()
for _, text := range m.steeringMessages {
m.printUserMessage(text)
}
m.steeringMessages = m.steeringMessages[:0]
m.distributeHeight()
cmds = append(cmds, m.drainScrollback())
} else {
// Case 2: post-turn — defer so SpinnerEvent orders correctly.
m.pendingUserPrints = append(m.pendingUserPrints, m.steeringMessages...)
m.steeringMessages = m.steeringMessages[:0]
m.distributeHeight()
}
case app.StepCompleteEvent:
// Keep stream content visible in the view — don't flush to scrollback
// yet. Flushing + resetting in the same frame would shrink the view
@@ -1641,6 +1723,7 @@ func (m *AppModel) View() tea.View {
// Propagate hint visibility to the input component before rendering.
if ic, ok := m.input.(*InputComponent); ok {
ic.hideHint = vis.HideInputHint
ic.agentBusy = m.state == stateWorking
}
// When a prompt is active, it replaces the input area for consistency
@@ -1935,16 +2018,26 @@ func (m *AppModel) cycleThinkingLevel() {
go func() { _ = SaveThinkingLevelPreference(next) }()
}
// renderSeparator renders the separator line with an optional queue count badge.
// renderSeparator renders the separator line with an optional queue/steer count badge.
func (m *AppModel) renderSeparator() string {
theme := GetTheme()
lineStyle := lipgloss.NewStyle().Foreground(theme.Muted)
queueLen := len(m.queuedMessages)
steerLen := len(m.steeringMessages)
if queueLen > 0 {
badge := lipgloss.NewStyle().
Foreground(theme.Secondary).
Render(fmt.Sprintf("%d queued", queueLen))
if steerLen > 0 || queueLen > 0 {
var parts []string
if steerLen > 0 {
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.Warning).
Render(fmt.Sprintf("%d steering", steerLen)))
}
if queueLen > 0 {
parts = append(parts, lipgloss.NewStyle().
Foreground(theme.Secondary).
Render(fmt.Sprintf("%d queued", queueLen)))
}
badge := strings.Join(parts, " ")
// Fill the separator with dashes up to the badge.
dashWidth := max(m.width-lipgloss.Width(badge)-1, 0)
@@ -2043,27 +2136,47 @@ func (m *AppModel) renderHeaderFooter(getter func() *WidgetData) string {
return renderContentBlock(data.Text, m.width, opts...)
}
// renderQueuedMessages renders queued prompts as styled content blocks with a
// "QUEUED" badge, anchored between the separator and input. Each message is
// displayed in a bordered block matching the overall message styling.
// renderQueuedMessages renders queued and steering prompts as styled content
// blocks with badges, anchored between the separator and input. Steering
// messages use a distinct "STEERING" badge to differentiate from queued ones.
func (m *AppModel) renderQueuedMessages() string {
if len(m.queuedMessages) == 0 {
if len(m.queuedMessages) == 0 && len(m.steeringMessages) == 0 {
return ""
}
theme := GetTheme()
badge := CreateBadge("QUEUED", theme.Accent)
var blocks []string
for _, msg := range m.queuedMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Muted),
)
blocks = append(blocks, rendered)
// Render steering messages first (higher priority).
if len(m.steeringMessages) > 0 {
badge := CreateBadge("STEERING", theme.Warning)
for _, msg := range m.steeringMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Warning),
)
blocks = append(blocks, rendered)
}
}
// Render queued messages.
if len(m.queuedMessages) > 0 {
badge := CreateBadge("QUEUED", theme.Accent)
for _, msg := range m.queuedMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Muted),
)
blocks = append(blocks, rendered)
}
}
return strings.Join(blocks, "\n")
}
@@ -2134,6 +2247,7 @@ func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
m.appCtrl.ClearQueue()
}
m.queuedMessages = m.queuedMessages[:0]
m.steeringMessages = m.steeringMessages[:0]
m.distributeHeight()
case "/tree":
@@ -2321,7 +2435,9 @@ func (m *AppModel) printHelpMessage() {
"- `!!command`: Run shell command, output excluded from LLM context\n\n" +
"**Keys:**\n" +
"- `Ctrl+C`: Exit at any time\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n" +
"- `Ctrl+S`: Steer — redirect the agent mid-turn (injected between tool calls)\n" +
"- `Enter` (while working): Queue message for after the agent finishes\n\n" +
"You can also just type your message to chat with the AI assistant."
m.printSystemMessage(help)
}
+5
View File
@@ -67,6 +67,11 @@ func (s *stubAppController) RunWithFiles(prompt string, _ []fantasy.FilePart) in
return s.queueLen
}
func (s *stubAppController) Steer(prompt string) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
// --------------------------------------------------------------------------
// Stub child components
// --------------------------------------------------------------------------
+13
View File
@@ -42,6 +42,9 @@ const (
// EventToolOutput fires when a tool produces streaming output chunks.
EventToolOutput EventType = "tool_output"
EventStepUsage EventType = "step_usage"
// EventSteerConsumed fires when one or more steering messages have been
// injected into the agent turn via PrepareStep.
EventSteerConsumed EventType = "steer_consumed"
)
// ---------------------------------------------------------------------------
@@ -276,6 +279,16 @@ type CompactionEvent struct {
// EventType implements Event.
func (e CompactionEvent) EventType() EventType { return EventCompaction }
// SteerConsumedEvent fires when one or more steering messages have been
// injected into the agent turn via PrepareStep. The Count indicates how
// many messages were consumed in this batch.
type SteerConsumedEvent struct {
Count int
}
// EventType implements Event.
func (e SteerConsumedEvent) EventType() EventType { return EventSteerConsumed }
// ---------------------------------------------------------------------------
// EventBus
// ---------------------------------------------------------------------------
+144
View File
@@ -2,6 +2,7 @@ package kit
import (
"strings"
"sync"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/extensions"
@@ -119,6 +120,125 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
})
}
// --- Subagent lifecycle events ---
// When an extension registers OnSubagentStart/Chunk/End handlers, bridge
// the SDK's per-subagent event stream (SubscribeSubagent) into the
// extension runner.
//
// Flow:
// ToolExecutionStartEvent(spawn_subagent) → emit SubagentStartEvent
// → SubscribeSubagent → emit SubagentChunkEvents
// ToolResultEvent(spawn_subagent) → emit SubagentEndEvent
//
// We use ToolExecutionStart (not ToolCall) for SubagentStart because that
// is when the subagent actually begins running. We use ToolResult for
// SubagentEnd because that carries the final response text.
wantsSubagent := runner.HasHandlers(extensions.SubagentStart) ||
runner.HasHandlers(extensions.SubagentChunk) ||
runner.HasHandlers(extensions.SubagentEnd)
if wantsSubagent {
// taskByCallID tracks the task description extracted from ToolCall input,
// keyed by toolCallID. Populated on ToolCall, consumed on ToolResult.
taskByCallID := make(map[string]string)
var taskMu = &taskMutex{}
// Intercept ToolCall to capture the task and subscribe to child events.
m.Subscribe(func(e Event) {
ev, ok := e.(ToolCallEvent)
if !ok || ev.ToolName != "spawn_subagent" {
return
}
// Extract task from parsed args.
task := ""
if ev.ParsedArgs != nil {
if t, ok := ev.ParsedArgs["task"].(string); ok {
task = t
}
}
taskMu.set(taskByCallID, ev.ToolCallID, task)
// Subscribe to child events so we can forward them as SubagentChunkEvents.
if runner.HasHandlers(extensions.SubagentChunk) {
m.SubscribeSubagent(ev.ToolCallID, func(childEvent Event) {
chunk := extensions.SubagentChunkEvent{
ToolCallID: ev.ToolCallID,
Task: task,
}
switch ce := childEvent.(type) {
case MessageUpdateEvent:
chunk.ChunkType = "text"
chunk.Content = ce.Chunk
case TurnStartEvent:
chunk.ChunkType = "turn_start"
case TurnEndEvent:
chunk.ChunkType = "turn_end"
case ToolCallEvent:
chunk.ChunkType = "tool_call"
chunk.ToolName = ce.ToolName
chunk.ToolArgs = ce.ToolArgs
case ToolExecutionStartEvent:
chunk.ChunkType = "tool_execution_start"
chunk.ToolName = ce.ToolName
case ToolExecutionEndEvent:
chunk.ChunkType = "tool_execution_end"
chunk.ToolName = ce.ToolName
case ToolResultEvent:
chunk.ChunkType = "tool_result"
chunk.ToolName = ce.ToolName
chunk.ToolResult = ce.Result
chunk.IsError = ce.IsError
default:
return // skip unknown event types
}
_, _ = runner.Emit(chunk)
})
}
})
// Emit SubagentStartEvent when execution begins.
if runner.HasHandlers(extensions.SubagentStart) {
m.Subscribe(func(e Event) {
ev, ok := e.(ToolExecutionStartEvent)
if !ok || ev.ToolName != "spawn_subagent" {
return
}
task := taskMu.get(taskByCallID, ev.ToolCallID)
_, _ = runner.Emit(extensions.SubagentStartEvent{
ToolCallID: ev.ToolCallID,
Task: task,
})
})
}
// Emit SubagentEndEvent when the tool result arrives.
if runner.HasHandlers(extensions.SubagentEnd) {
m.Subscribe(func(e Event) {
ev, ok := e.(ToolResultEvent)
if !ok || ev.ToolName != "spawn_subagent" {
return
}
task := taskMu.get(taskByCallID, ev.ToolCallID)
taskMu.del(taskByCallID, ev.ToolCallID)
errMsg := ""
if ev.IsError {
errMsg = ev.Result
}
response := ""
if !ev.IsError {
response = ev.Result
}
_, _ = runner.Emit(extensions.SubagentEndEvent{
ToolCallID: ev.ToolCallID,
Task: task,
Response: response,
ErrorMsg: errMsg,
})
})
}
}
// --- Context filtering hook ---
// Extension ContextPrepare → SDK ContextPrepare hook.
if runner.HasHandlers(extensions.ContextPrepare) {
@@ -204,3 +324,27 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
})
}
}
// taskMutex is a simple mutex-protected map helper used by bridgeExtensions.
// It lives in this file to avoid polluting the kit package with unexported types.
type taskMutex struct {
mu sync.Mutex
}
func (t *taskMutex) set(m map[string]string, key, val string) {
t.mu.Lock()
m[key] = val
t.mu.Unlock()
}
func (t *taskMutex) get(m map[string]string, key string) string {
t.mu.Lock()
defer t.mu.Unlock()
return m[key]
}
func (t *taskMutex) del(m map[string]string, key string) {
t.mu.Lock()
delete(m, key)
t.mu.Unlock()
}
+101
View File
@@ -66,6 +66,13 @@ type Kit struct {
// subagentListeners holds per-tool-call event listeners registered via
// SubscribeSubagent(). Keyed by toolCallID → *subagentListenerSet.
subagentListeners sync.Map
// steerCh is a buffered channel used to inject steering messages into
// the running agent turn via Fantasy's PrepareStep. Created fresh for
// each generate() call and set to nil when idle. Protected by steerMu.
steerMu sync.Mutex
steerCh chan string
leftoverSteer []string // unconsumed steer messages from the last turn
}
// Subscribe registers an EventListener that will be called for every lifecycle
@@ -1405,6 +1412,35 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
// All prompt modes (Prompt, Steer, FollowUp, PromptWithOptions) share this
// single code path so callback wiring is never duplicated.
func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) {
// Create a per-turn steer channel and attach it to the context so the
// agent's PrepareStep can inject steering messages between steps.
steerCh := make(chan string, 16)
m.steerMu.Lock()
m.steerCh = steerCh
m.steerMu.Unlock()
defer func() {
// Drain any unconsumed steer messages before nilling the channel.
// These are stored in leftoverSteer so DrainSteer() can return them.
var leftover []string
for {
select {
case msg := <-steerCh:
leftover = append(leftover, msg)
default:
goto drained
}
}
drained:
m.steerMu.Lock()
m.steerCh = nil
m.leftoverSteer = leftover
m.steerMu.Unlock()
}()
ctx = agent.ContextWithSteerCh(ctx, steerCh)
ctx = agent.ContextWithSteerConsumed(ctx, func(count int) {
m.events.emit(SteerConsumedEvent{Count: count})
})
// Inject the in-process subagent spawner into the context so the
// spawn_subagent core tool can create child Kit instances without
// importing pkg/kit (which would create an import cycle).
@@ -1714,6 +1750,71 @@ func (m *Kit) FollowUp(ctx context.Context, text string) (string, error) {
return result.Response, nil
}
// InjectSteer sends a steering message into the currently active agent turn.
// The message will be injected as a user message between steps (after the
// current tool execution finishes, before the next LLM call). If no turn is
// active the message is silently dropped — callers should check IsGenerating()
// or use Prompt()/Steer() for idle-state messaging.
//
// InjectSteer is safe to call from any goroutine. Multiple calls queue
// messages in order; all pending steer messages are drained and injected
// together at the next step boundary.
//
// This is the preferred way to redirect an agent mid-turn without cancelling
// in-progress tool execution.
func (m *Kit) InjectSteer(message string) {
m.steerMu.Lock()
ch := m.steerCh
m.steerMu.Unlock()
if ch == nil {
return
}
select {
case ch <- message:
default:
// Channel full — extremely unlikely with buffer of 16, but don't block.
}
}
// IsGenerating returns true if an agent turn is currently in progress.
// Use this to decide between InjectSteer (mid-turn) and Prompt (new turn).
func (m *Kit) IsGenerating() bool {
m.steerMu.Lock()
defer m.steerMu.Unlock()
return m.steerCh != nil
}
// DrainSteer removes and returns all unconsumed steer messages. Called after
// a turn completes so the app layer can process any steer messages that
// arrived after the last PrepareStep fired (e.g. during a text-only response
// with no tool calls, or after the agent finished its last step).
func (m *Kit) DrainSteer() []string {
m.steerMu.Lock()
defer m.steerMu.Unlock()
// First check leftover messages saved when generate() returned.
if len(m.leftoverSteer) > 0 {
msgs := m.leftoverSteer
m.leftoverSteer = nil
return msgs
}
// If a turn is still active, drain from the live channel.
if m.steerCh != nil {
var msgs []string
for {
select {
case msg := <-m.steerCh:
msgs = append(msgs, msg)
default:
return msgs
}
}
}
return nil
}
// PromptOptions configures a single PromptWithOptions call.
type PromptOptions struct {
// SystemMessage is prepended as a system message before the user prompt.
+73
View File
@@ -59,6 +59,79 @@ result := ctx.SpawnSubagent(ext.SubagentConfig{
})
```
### Monitoring subagents from extensions
When the LLM (not the extension itself) spawns a subagent using the `spawn_subagent` tool, extensions can monitor its activity in real-time using three lifecycle event handlers:
```go
// Track active subagents and display their output
var subagentWidgets map[string]*SubagentWidget
func Init(api ext.API) {
// Subagent started by the main agent
api.OnSubagentStart(func(e ext.SubagentStartEvent, ctx ext.Context) {
// e.ToolCallID — unique ID for this subagent invocation
// e.Task — the task/prompt sent to the subagent
widget := NewWidget(e.ToolCallID, e.Task)
subagentWidgets[e.ToolCallID] = widget
ctx.SetWidget(widget.Config())
})
// Real-time streaming from subagent
api.OnSubagentChunk(func(e ext.SubagentChunkEvent, ctx ext.Context) {
// e.ToolCallID — matches the start event
// e.ChunkType — "text", "tool_call", "tool_execution_start", "tool_result"
// e.Content — text content
// e.ToolName — tool name (for tool chunks)
// e.IsError — true if tool result failed
widget := subagentWidgets[e.ToolCallID]
if widget != nil {
widget.AddOutput(e)
ctx.SetWidget(widget.Config())
}
})
// Subagent completed
api.OnSubagentEnd(func(e ext.SubagentEndEvent, ctx ext.Context) {
// e.Response — final response from subagent
// e.ErrorMsg — error message if subagent failed
widget := subagentWidgets[e.ToolCallID]
if widget != nil {
widget.MarkComplete(e.Response, e.ErrorMsg)
ctx.SetWidget(widget.Config())
delete(subagentWidgets, e.ToolCallID)
}
})
}
```
**Event structs:**
```go
type SubagentStartEvent struct {
ToolCallID string // Unique ID for this subagent invocation
Task string // The task/prompt sent to subagent
}
type SubagentChunkEvent struct {
ToolCallID string // Matches SubagentStartEvent.ToolCallID
Task string // Task description
ChunkType string // "text", "tool_call", "tool_execution_start", "tool_result"
Content string // For text chunks
ToolName string // For tool-related chunks
IsError bool // For tool_result chunks
}
type SubagentEndEvent struct {
ToolCallID string // Matches start event
Task string // Task description
Response string // Final response from subagent
ErrorMsg string // Error message if failed
}
```
This enables building monitoring widgets that display real-time activity from all subagents spawned by the main agent. See the `subagent-monitor.go` example for a complete implementation with horizontal widget layouts and scrolling output.
## Go SDK subagents
The SDK provides in-process subagent spawning:
+52 -1
View File
@@ -7,7 +7,7 @@ description: All extension capabilities — lifecycle events, tools, commands, w
## Lifecycle events
Extensions can hook into 20 lifecycle events:
Extensions can hook into 23 lifecycle events:
| Event | Description |
|-------|-------------|
@@ -31,6 +31,9 @@ Extensions can hook into 20 lifecycle events:
| `OnBeforeSessionSwitch` | Before switching sessions |
| `OnBeforeCompact` | Before conversation compaction |
| `OnCustomEvent` | Custom inter-extension event received |
| `OnSubagentStart` | Subagent spawned by the main agent |
| `OnSubagentChunk` | Real-time output from subagent (text, tool calls, results) |
| `OnSubagentEnd` | Subagent completed with final response/error |
### Example
@@ -234,6 +237,54 @@ result := ctx.SpawnSubagent(ext.SubagentConfig{
})
```
### Monitoring subagents spawned by the main agent
When the LLM uses the built-in `spawn_subagent` tool, extensions can monitor the subagent's activity in real-time using three lifecycle events:
```go
// Subagent started
api.OnSubagentStart(func(e ext.SubagentStartEvent, ctx ext.Context) {
// e.ToolCallID — unique ID for this subagent invocation
// e.Task — the task/prompt sent to the subagent
ctx.PrintInfo(fmt.Sprintf("Subagent started: %s", e.Task))
})
// Real-time streaming output from subagent
api.OnSubagentChunk(func(e ext.SubagentChunkEvent, ctx ext.Context) {
// e.ToolCallID — matches the start event
// e.Task — task description
// e.ChunkType — "text", "tool_call", "tool_execution_start", "tool_result"
// e.Content — text content (for text chunks)
// e.ToolName — tool name (for tool-related chunks)
// e.IsError — true if tool result is an error
switch e.ChunkType {
case "text":
// Streaming text output
case "tool_call":
// Subagent is calling a tool
case "tool_execution_start":
// Tool execution started
case "tool_result":
// Tool execution completed (check e.IsError)
}
})
// Subagent completed
api.OnSubagentEnd(func(e ext.SubagentEndEvent, ctx ext.Context) {
// e.ToolCallID — matches start event
// e.Task — task description
// e.Response — final response from subagent
// e.ErrorMsg — error message if subagent failed
if e.ErrorMsg != "" {
ctx.PrintError(fmt.Sprintf("Subagent failed: %s", e.ErrorMsg))
} else {
ctx.PrintInfo(fmt.Sprintf("Subagent completed: %s", e.Response))
}
})
```
This enables building widgets that display real-time subagent activity. See the `subagent-monitor.go` example for a complete implementation showing horizontal widget layouts with scrolling output from multiple parallel subagents.
## LLM completion
Make direct model calls without going through the agent loop:
+2
View File
@@ -64,6 +64,7 @@ Kit ships with a rich set of example extensions in the `examples/extensions/` di
| [`kit-kit.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/kit-kit.go) | Kit-in-Kit sub-agent spawning |
| [`subagent-widget.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/subagent-widget.go) | Multi-agent orchestration with status widget |
| [`subagent-test.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/subagent-test.go) | Subagent testing utilities |
| [`subagent-monitor.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/subagent-monitor.go) | Real-time monitoring widget for spawned subagents |
## Development
@@ -71,6 +72,7 @@ Kit ships with a rich set of example extensions in the `examples/extensions/` di
|-----------|-------------|
| [`dev-reload.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/dev-reload.go) | Development live-reload |
| [`tool-logger_test.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/tool-logger_test.go) | Example extension tests (see [Testing](/extensions/testing)) |
| [`subagent-monitor_test.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/subagent-monitor_test.go) | Subagent lifecycle event tests |
| [`extension_test_template.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/extension_test_template.go) | Copy-and-paste test template for your extensions |
## Subdirectory extensions