mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66f2ba543b | |||
| 6dd052b990 | |||
| ef8628eecc | |||
| 3167222b72 | |||
| e3b37191b1 | |||
| 41d5f5e0fb | |||
| 3ad0b3616d | |||
| 8831b49b51 | |||
| c94edc929b | |||
| e49194a0d4 | |||
| 46b1acf444 | |||
| 6a6d201a50 | |||
| 930cbcb4f2 | |||
| 12e1ef2036 | |||
| a05da5f3ab | |||
| fefbf19b42 | |||
| 93905d4d77 | |||
| 7268ccdf4d | |||
| 9f59fa42dc | |||
| 8af7ca8455 | |||
| 424847f0db | |||
| 4c126ca41b | |||
| 4bdc4f75cc | |||
| bbd8975ca0 | |||
| e613a07773 | |||
| 1d3b4f8d56 | |||
| 118af2e152 | |||
| c46687fc44 | |||
| aeaa5368af |
@@ -0,0 +1,853 @@
|
||||
---
|
||||
name: kit-extensions
|
||||
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
|
||||
---
|
||||
|
||||
# Kit Extensions Development Guide
|
||||
|
||||
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
|
||||
|
||||
## Extension Structure
|
||||
|
||||
Every extension must export a `package main` with an `Init(api ext.API)` function:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register event handlers, tools, commands, etc.
|
||||
}
|
||||
```
|
||||
|
||||
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
|
||||
|
||||
## Extension Locations
|
||||
|
||||
Extensions are auto-loaded from these directories:
|
||||
|
||||
- `~/.config/kit/extensions/*.go` (global, single files)
|
||||
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
|
||||
- `.kit/extensions/*.go` (project-local, single files)
|
||||
- `.kit/extensions/*/main.go` (project-local, subdirectories)
|
||||
|
||||
Or loaded explicitly:
|
||||
|
||||
```bash
|
||||
kit -e path/to/extension.go
|
||||
kit --extension path/to/extension.go
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
|
||||
|
||||
## API Overview
|
||||
|
||||
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Events
|
||||
|
||||
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
|
||||
|
||||
### Session Events
|
||||
|
||||
```go
|
||||
// Fired when session is loaded/created.
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
// e.SessionID string
|
||||
})
|
||||
|
||||
// Fired when Kit is shutting down. Use for cleanup.
|
||||
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
// No fields.
|
||||
})
|
||||
```
|
||||
|
||||
### Agent Turn Events
|
||||
|
||||
```go
|
||||
// Before agent starts processing. Can inject system prompt or text.
|
||||
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
// e.Prompt string
|
||||
// Return nil to pass through.
|
||||
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
|
||||
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Agent loop has started.
|
||||
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
|
||||
// e.Prompt string
|
||||
})
|
||||
|
||||
// Agent finished responding.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// e.Response string
|
||||
// e.StopReason string — "completed", "cancelled", "error"
|
||||
})
|
||||
```
|
||||
|
||||
### Tool Events
|
||||
|
||||
```go
|
||||
// Before a tool executes. Can block the call.
|
||||
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
// e.ToolName string
|
||||
// e.ToolCallID string
|
||||
// e.Input string — JSON-encoded parameters
|
||||
// e.Source string — "llm" or "user"
|
||||
// Return nil to allow.
|
||||
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Tool execution started (informational only).
|
||||
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// Tool execution ended (informational only).
|
||||
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// After a tool returns. Can modify the result.
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
// e.ToolName string
|
||||
// e.Input string
|
||||
// e.Content string
|
||||
// e.IsError bool
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ToolResultResult{Content: &s} to replace content.
|
||||
// Return &ext.ToolResultResult{IsError: &b} to change error status.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Input Events
|
||||
|
||||
```go
|
||||
// User submitted input. Can handle or transform it.
|
||||
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
// e.Text string
|
||||
// e.Source string — "interactive", "cli", "script", "queue"
|
||||
// Return nil to pass through to agent.
|
||||
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
|
||||
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming Events
|
||||
|
||||
```go
|
||||
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
|
||||
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
|
||||
// e.Chunk string — streaming text chunk
|
||||
})
|
||||
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
|
||||
// e.Content string — full message content
|
||||
})
|
||||
```
|
||||
|
||||
### Model Events
|
||||
|
||||
```go
|
||||
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
|
||||
// e.NewModel string
|
||||
// e.PreviousModel string
|
||||
// e.Source string — "extension" or "user"
|
||||
})
|
||||
```
|
||||
|
||||
### Context Filtering
|
||||
|
||||
```go
|
||||
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
// e.Messages []ext.ContextMessage
|
||||
// Each ContextMessage has: Index int, Role string, Content string
|
||||
// Index -1 means a new injected message (not from session).
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Session Control Events
|
||||
|
||||
```go
|
||||
// Before forking the session tree. Can cancel.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
// e.TargetID string, e.IsUserMessage bool, e.UserText string
|
||||
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before switching/clearing session. Can cancel.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
// e.Reason string — "new" or "clear"
|
||||
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before context compaction. Can cancel.
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
// e.EstimatedTokens, e.ContextLimit int
|
||||
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
|
||||
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Events
|
||||
|
||||
```go
|
||||
// Subscribe to custom events emitted by other extensions.
|
||||
api.OnCustomEvent("event-name", func(data string) {
|
||||
// data is arbitrary string payload
|
||||
})
|
||||
|
||||
// Emit from Context:
|
||||
ctx.EmitCustomEvent("event-name", "payload")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Tools
|
||||
|
||||
Tools are functions the LLM can invoke:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "current_time",
|
||||
Description: "Get the current date and time",
|
||||
Parameters: `{"type":"object","properties":{}}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
return time.Now().Format(time.RFC3339), nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
For long-running tools with cancellation and progress:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "slow_task",
|
||||
Description: "A long-running task with progress reporting",
|
||||
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
|
||||
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
if tc.IsCancelled() {
|
||||
return "cancelled", nil
|
||||
}
|
||||
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return "done", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
|
||||
|
||||
---
|
||||
|
||||
## Registering Slash Commands
|
||||
|
||||
Commands are user-facing actions invoked with `/name` in the input:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "echo",
|
||||
Description: "Echo back the provided text",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.PrintInfo("You said: " + args)
|
||||
return "", nil
|
||||
},
|
||||
// Optional tab-completion:
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
return []string{"hello", "world"}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
|
||||
|
||||
---
|
||||
|
||||
## Registering Keyboard Shortcuts
|
||||
|
||||
```go
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan mode",
|
||||
}, func(ctx ext.Context) {
|
||||
// handler runs when shortcut is pressed
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Options
|
||||
|
||||
Options are configurable values resolved from env vars, config, or defaults:
|
||||
|
||||
```go
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "my-setting",
|
||||
Description: "Controls something",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
|
||||
val := ctx.GetOption("my-setting")
|
||||
|
||||
// Set at runtime:
|
||||
ctx.SetOption("my-setting", "true")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context API Reference
|
||||
|
||||
The `ext.Context` struct provides runtime capabilities via function fields.
|
||||
|
||||
### Output
|
||||
|
||||
```go
|
||||
ctx.Print("plain text") // plain output
|
||||
ctx.PrintInfo("styled info block") // bordered info block
|
||||
ctx.PrintError("styled error block") // red error block
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
|
||||
Text: "content",
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "my-ext",
|
||||
})
|
||||
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
|
||||
```
|
||||
|
||||
### Message Injection
|
||||
|
||||
```go
|
||||
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
|
||||
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
|
||||
```
|
||||
|
||||
### Widgets
|
||||
|
||||
Persistent UI elements displayed above or below the input area:
|
||||
|
||||
```go
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "my-widget",
|
||||
Placement: ext.WidgetAbove, // or ext.WidgetBelow
|
||||
Content: ext.WidgetContent{
|
||||
Text: "Status: Active",
|
||||
Markdown: false, // set true for markdown rendering
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1", // hex color
|
||||
NoBorder: false,
|
||||
},
|
||||
Priority: 0, // lower values render first
|
||||
})
|
||||
|
||||
ctx.RemoveWidget("my-widget")
|
||||
```
|
||||
|
||||
### Header and Footer
|
||||
|
||||
```go
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Header"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
ctx.RemoveHeader()
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Footer"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
ctx.RemoveFooter()
|
||||
```
|
||||
|
||||
### Status Bar
|
||||
|
||||
```go
|
||||
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
|
||||
ctx.RemoveStatus("key")
|
||||
```
|
||||
|
||||
### Interactive Prompts
|
||||
|
||||
These block until the user responds (safe in slash commands and goroutines):
|
||||
|
||||
```go
|
||||
// Selection list
|
||||
result := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Pick one:",
|
||||
Options: []string{"Option A", "Option B", "Option C"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string, result.Index int
|
||||
}
|
||||
|
||||
// Yes/No confirmation
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Are you sure?",
|
||||
DefaultValue: false,
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value bool
|
||||
}
|
||||
|
||||
// Text input
|
||||
result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Enter name:",
|
||||
Placeholder: "my-project",
|
||||
Default: "",
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string
|
||||
}
|
||||
```
|
||||
|
||||
### Overlay Dialogs
|
||||
|
||||
Modal dialogs with optional action buttons:
|
||||
|
||||
```go
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Confirmation",
|
||||
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
|
||||
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
|
||||
Width: 60, // 0 = 60% of terminal width
|
||||
MaxHeight: 20, // 0 = 80% of terminal height
|
||||
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
|
||||
Actions: []string{"Confirm", "Cancel"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Action string, result.Index int
|
||||
}
|
||||
```
|
||||
|
||||
### Editor Interceptor
|
||||
|
||||
Wrap the built-in text input with custom key handling and rendering:
|
||||
|
||||
```go
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
|
||||
if key == "ctrl+s" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
Render: func(width int, defaultContent string) string {
|
||||
return "[custom] " + defaultContent
|
||||
},
|
||||
})
|
||||
|
||||
ctx.ResetEditor() // remove interceptor
|
||||
ctx.SetEditorText("prefilled") // set editor text content
|
||||
```
|
||||
|
||||
**EditorKeyAction types:**
|
||||
- `ext.EditorKeyPassthrough` — let the default editor handle the key
|
||||
- `ext.EditorKeyConsumed` — swallow the key, do nothing
|
||||
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
|
||||
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
|
||||
|
||||
### UI Visibility
|
||||
|
||||
```go
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Session Data
|
||||
|
||||
```go
|
||||
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
|
||||
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
|
||||
path := ctx.GetSessionPath() // file path of session JSONL
|
||||
|
||||
// Persist custom data in the session tree:
|
||||
id, err := ctx.AppendEntry("my-type", "data string")
|
||||
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
|
||||
```
|
||||
|
||||
### Model Management
|
||||
|
||||
```go
|
||||
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
|
||||
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
|
||||
```
|
||||
|
||||
### Tool Management
|
||||
|
||||
```go
|
||||
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
|
||||
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
```
|
||||
|
||||
### LLM Completions
|
||||
|
||||
Make standalone LLM calls (bypasses the agent tool loop):
|
||||
|
||||
```go
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
Model: "", // empty = current model
|
||||
System: "You are ...", // optional system prompt
|
||||
Prompt: "Summarize...", // the prompt
|
||||
MaxTokens: 1000, // 0 = provider default
|
||||
OnChunk: func(chunk string) { /* streaming */ },
|
||||
})
|
||||
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
|
||||
```
|
||||
|
||||
### TUI Suspension
|
||||
|
||||
Temporarily release the terminal for interactive subprocesses:
|
||||
|
||||
```go
|
||||
ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("vim", "file.go")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
```
|
||||
|
||||
### Application Control
|
||||
|
||||
```go
|
||||
ctx.Exit() // graceful shutdown
|
||||
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
|
||||
```
|
||||
|
||||
### Context Fields
|
||||
|
||||
```go
|
||||
ctx.SessionID // string
|
||||
ctx.CWD // string — current working directory
|
||||
ctx.Model // string — active model name
|
||||
ctx.Interactive // bool — true if running in TUI mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Renderers
|
||||
|
||||
Customize how tool calls are displayed in the TUI:
|
||||
|
||||
```go
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "bash",
|
||||
DisplayName: "Shell", // replaces auto-capitalized name
|
||||
BorderColor: "#89b4fa",
|
||||
Background: "",
|
||||
BodyMarkdown: true, // render body through markdown
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(toolArgs), &args)
|
||||
return "$ " + args.Command
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "ERROR: " + toolResult
|
||||
}
|
||||
return toolResult
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Message Renderers
|
||||
|
||||
Define named output styles for `ctx.RenderMessage()`:
|
||||
|
||||
```go
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
return " " + content // green checkmark prefix
|
||||
},
|
||||
})
|
||||
|
||||
// Usage in handlers:
|
||||
ctx.RenderMessage("success", "All tests passed")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Yaegi Constraints
|
||||
|
||||
### No Named Function References in Struct Fields
|
||||
|
||||
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
|
||||
|
||||
```go
|
||||
// WRONG - will silently return zero values:
|
||||
func myHandler(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
|
||||
|
||||
// CORRECT - use anonymous closure:
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
|
||||
|
||||
### No Interfaces Across the Boundary
|
||||
|
||||
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
|
||||
|
||||
### Package-Level Variables for State
|
||||
|
||||
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
var callCount int
|
||||
var lastTool string
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
callCount++
|
||||
lastTool = e.ToolName
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Tool Call Blocking
|
||||
|
||||
Block dangerous operations by intercepting tool calls:
|
||||
|
||||
```go
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName == "bash" {
|
||||
var input struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(tc.Input), &input)
|
||||
if strings.Contains(input.Command, "rm -rf") {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Dangerous command blocked",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: System Prompt Injection
|
||||
|
||||
Augment the agent's behavior by injecting instructions:
|
||||
|
||||
```go
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
prompt := "Always respond with bullet points."
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Background Processing with SendMessage
|
||||
|
||||
Run work in a goroutine and inject results back:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command in the background",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
go func() {
|
||||
out, err := exec.Command("sh", "-c", args).CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
|
||||
return
|
||||
}
|
||||
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
|
||||
}()
|
||||
return "Running in background...", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Ephemeral Context Injection
|
||||
|
||||
Inject information into every LLM turn without persisting in session history:
|
||||
|
||||
```go
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
data, err := os.ReadFile(".kit/context.md")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1, // -1 = new message, not from session
|
||||
Role: "system",
|
||||
Content: string(data),
|
||||
}
|
||||
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Live Widget Updates
|
||||
|
||||
Update a widget periodically from a goroutine:
|
||||
|
||||
```go
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "clock",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
}
|
||||
}()
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Spawning Kit as a Sub-Agent
|
||||
|
||||
Extensions can spawn Kit as a subprocess for delegation:
|
||||
|
||||
```bash
|
||||
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
|
||||
```
|
||||
|
||||
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
|
||||
|
||||
---
|
||||
|
||||
## Testing Extensions
|
||||
|
||||
```bash
|
||||
# Validate syntax of all discovered extensions
|
||||
kit extensions validate
|
||||
|
||||
# List loaded extensions
|
||||
kit extensions list
|
||||
|
||||
# Run with a specific extension
|
||||
kit -e path/to/extension.go
|
||||
|
||||
# Run with multiple extensions
|
||||
kit -e ext1.go -e ext2.go
|
||||
|
||||
# Disable all extensions
|
||||
kit --no-extensions
|
||||
|
||||
# Generate an example extension scaffold
|
||||
kit extensions init
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example: Plan Mode
|
||||
|
||||
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
var planActive bool
|
||||
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
if strings.ToLower(ctx.GetOption("plan")) == "true" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
|
||||
Focus on understanding, analysis, and generating plans.`
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, tools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(tools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil)
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files for Reference
|
||||
|
||||
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
|
||||
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
|
||||
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
|
||||
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
|
||||
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
|
||||
@@ -24,6 +24,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
|
||||
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
|
||||
- **Session Management**: Tree-based conversation history with branching support
|
||||
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
|
||||
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
|
||||
- **Go SDK**: Embed Kit in your own applications
|
||||
|
||||
## Installation
|
||||
@@ -82,6 +83,20 @@ kit "Run tests" --quiet
|
||||
kit "Quick question" --no-session
|
||||
```
|
||||
|
||||
### ACP Server Mode
|
||||
|
||||
Kit can run as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server, enabling ACP-compatible clients (such as [OpenCode](https://github.com/sst/opencode)) to drive Kit as a remote coding agent over stdio.
|
||||
|
||||
```bash
|
||||
# Start Kit as an ACP server (communicates via JSON-RPC 2.0 on stdin/stdout)
|
||||
kit acp
|
||||
|
||||
# With debug logging to stderr
|
||||
kit acp --debug
|
||||
```
|
||||
|
||||
The ACP server exposes Kit's full capabilities — LLM execution, tool calls (bash, read, write, edit, grep, etc.), and session persistence — over the standard ACP protocol. Sessions are persisted to Kit's normal JSONL session files, so they can be resumed later.
|
||||
|
||||
## Configuration
|
||||
|
||||
Kit looks for configuration in the following locations (in order of priority):
|
||||
@@ -188,6 +203,10 @@ kit update-models # Update local model database from models.dev
|
||||
kit extensions list # List discovered extensions
|
||||
kit extensions validate # Validate extension files
|
||||
kit extensions init # Generate example extension template
|
||||
|
||||
# ACP server
|
||||
kit acp # Start as ACP agent (stdio JSON-RPC)
|
||||
kit acp --debug # With debug logging to stderr
|
||||
```
|
||||
|
||||
## Extension System
|
||||
@@ -458,6 +477,7 @@ internal/extensions/ - Yaegi extension system
|
||||
internal/core/ - Built-in tools
|
||||
internal/tools/ - MCP tool integration
|
||||
internal/config/ - Configuration management
|
||||
internal/acpserver/ - ACP (Agent Client Protocol) server
|
||||
internal/session/ - Session persistence
|
||||
internal/models/ - Provider and model management
|
||||
examples/extensions/ - Example extension files
|
||||
|
||||
+13
-1
@@ -64,8 +64,20 @@
|
||||
"name": "yaegi",
|
||||
"url": "https://github.com/traefik/yaegi",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"name": "acp-go-sdk",
|
||||
"url": "https://github.com/coder/acp-go-sdk",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"name": "opencode",
|
||||
"url": "https://github.com/anomalyco/opencode",
|
||||
"branch": "dev"
|
||||
}
|
||||
],
|
||||
"model": "claude-haiku-4-5",
|
||||
"provider": "opencode"
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
|
||||
"github.com/mark3labs/kit/internal/acpserver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var acpCmd = &cobra.Command{
|
||||
Use: "acp",
|
||||
Short: "Start Kit as an ACP agent server",
|
||||
Long: `Start Kit as an ACP (Agent Client Protocol) agent server.
|
||||
|
||||
Communicates over stdio (stdin/stdout) using JSON-RPC 2.0 with
|
||||
newline-delimited JSON, compatible with OpenCode and other ACP clients.
|
||||
|
||||
The server exposes Kit's LLM execution, tool system, and session
|
||||
management via the Agent Client Protocol. Sessions are persisted
|
||||
to Kit's standard JSONL session files.`,
|
||||
RunE: runACP,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(acpCmd)
|
||||
}
|
||||
|
||||
func runACP(cmd *cobra.Command, _ []string) error {
|
||||
// Create the ACP agent implementation.
|
||||
agent := acpserver.NewAgent()
|
||||
defer agent.Close()
|
||||
|
||||
// Create the stdio connection. The SDK reads JSON-RPC from stdin and
|
||||
// writes responses to stdout. We wrap stdin with a normalizer that
|
||||
// fills in optional fields the SDK's generated validation requires
|
||||
// (e.g. mcpServers) so clients that omit them still work.
|
||||
conn := acp.NewAgentSideConnection(agent, os.Stdout, newACPNormalizer(os.Stdin))
|
||||
|
||||
// Wire the connection back to the agent so it can send session updates.
|
||||
agent.SetAgentConnection(conn)
|
||||
|
||||
// Enable debug logging to stderr if requested.
|
||||
if debugMode {
|
||||
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})))
|
||||
}
|
||||
|
||||
// Wait for either the client to disconnect or a signal.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-conn.Done():
|
||||
fmt.Fprintln(os.Stderr, "kit: ACP client disconnected")
|
||||
case sig := <-sigCh:
|
||||
fmt.Fprintf(os.Stderr, "kit: received %s, shutting down\n", sig)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// acpNormalizer wraps an io.Reader carrying newline-delimited JSON-RPC and
|
||||
// patches incoming messages so that fields the SDK validates as required —
|
||||
// but that some clients (e.g. Zed) omit — are defaulted. This avoids
|
||||
// InvalidParams errors without forking the SDK.
|
||||
type acpNormalizer struct {
|
||||
scanner *bufio.Scanner
|
||||
buf bytes.Buffer // leftover bytes from the last normalized line
|
||||
}
|
||||
|
||||
func newACPNormalizer(r io.Reader) *acpNormalizer {
|
||||
const maxMsg = 10 * 1024 * 1024 // 10 MB, matches SDK buffer
|
||||
s := bufio.NewScanner(r)
|
||||
s.Buffer(make([]byte, 0, 1024*1024), maxMsg)
|
||||
return &acpNormalizer{scanner: s}
|
||||
}
|
||||
|
||||
// Read satisfies io.Reader. It feeds one normalized JSON line (plus newline)
|
||||
// per underlying scan, buffering across short caller reads.
|
||||
func (n *acpNormalizer) Read(p []byte) (int, error) {
|
||||
// Drain any leftover bytes from the previous line first.
|
||||
if n.buf.Len() > 0 {
|
||||
return n.buf.Read(p)
|
||||
}
|
||||
|
||||
if !n.scanner.Scan() {
|
||||
if err := n.scanner.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
line := n.scanner.Bytes()
|
||||
normalized := normalizeACPLine(line)
|
||||
n.buf.Write(normalized)
|
||||
n.buf.WriteByte('\n')
|
||||
return n.buf.Read(p)
|
||||
}
|
||||
|
||||
// normalizeACPLine ensures session/new and session/load params contain an
|
||||
// mcpServers array. Returns the original line unchanged for all other methods.
|
||||
func normalizeACPLine(line []byte) []byte {
|
||||
// Quick check: if it already contains mcpServers, nothing to do.
|
||||
if bytes.Contains(line, []byte(`"mcpServers"`)) {
|
||||
return line
|
||||
}
|
||||
|
||||
// Only bother parsing if the method could be session/new or session/load.
|
||||
if !bytes.Contains(line, []byte(`"session/new"`)) &&
|
||||
!bytes.Contains(line, []byte(`"session/load"`)) {
|
||||
return line
|
||||
}
|
||||
|
||||
var msg struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
return line
|
||||
}
|
||||
if msg.Method != "session/new" && msg.Method != "session/load" {
|
||||
return line
|
||||
}
|
||||
|
||||
// Patch params to include mcpServers: [].
|
||||
var params map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg.Params, ¶ms); err != nil {
|
||||
return line
|
||||
}
|
||||
if _, ok := params["mcpServers"]; ok {
|
||||
return line
|
||||
}
|
||||
params["mcpServers"] = json.RawMessage(`[]`)
|
||||
|
||||
patched, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
msg.Params = patched
|
||||
|
||||
out, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return out
|
||||
}
|
||||
+85
-6
@@ -924,6 +924,42 @@ func runNormalMode(ctx context.Context) error {
|
||||
Index: resp.Index,
|
||||
}
|
||||
},
|
||||
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
// In-process subagent via SDK.
|
||||
sdkCfg := kit.SubagentConfig{
|
||||
Prompt: config.Prompt,
|
||||
Model: config.Model,
|
||||
SystemPrompt: config.SystemPrompt,
|
||||
Timeout: config.Timeout,
|
||||
NoSession: config.NoSession,
|
||||
}
|
||||
// Bridge SDK events to extension SubagentEvents.
|
||||
if config.OnEvent != nil {
|
||||
sdkCfg.OnEvent = func(e kit.Event) {
|
||||
se := sdkEventToSubagentEvent(e)
|
||||
if se.Type != "" {
|
||||
config.OnEvent(se)
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := kitInstance.Subagent(ctx, sdkCfg)
|
||||
if result == nil {
|
||||
return nil, &extensions.SubagentResult{Error: err}, err
|
||||
}
|
||||
extResult := &extensions.SubagentResult{
|
||||
Response: result.Response,
|
||||
Error: result.Error,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
extResult.Usage = &extensions.SubagentUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
})
|
||||
kitInstance.EmitSessionStart()
|
||||
}
|
||||
@@ -1083,15 +1119,19 @@ func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
}
|
||||
type jsonEnvelope struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
Usage *jsonUsage `json:"usage,omitempty"`
|
||||
Messages []jsonMessage `json:"messages"`
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Usage *jsonUsage `json:"usage,omitempty"`
|
||||
Messages []jsonMessage `json:"messages"`
|
||||
}
|
||||
|
||||
out := jsonEnvelope{
|
||||
Response: result.Response,
|
||||
Model: model,
|
||||
Response: result.Response,
|
||||
Model: model,
|
||||
StopReason: result.StopReason,
|
||||
SessionID: result.SessionID,
|
||||
}
|
||||
|
||||
if result.TotalUsage != nil {
|
||||
@@ -1202,3 +1242,42 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
_, runErr := program.Run()
|
||||
return runErr
|
||||
}
|
||||
|
||||
// sdkEventToSubagentEvent converts an SDK event to an extension-facing
|
||||
// SubagentEvent. Returns a zero-value event (Type=="") for events that
|
||||
// don't map to anything useful.
|
||||
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
|
||||
case kit.ReasoningDeltaEvent:
|
||||
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
|
||||
case kit.ToolCallEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_call", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
|
||||
}
|
||||
case kit.ToolExecutionStartEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolExecutionEndEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolResultEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_result", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
ToolResult: ev.Result, IsError: ev.IsError,
|
||||
}
|
||||
case kit.TurnStartEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_start"}
|
||||
case kit.TurnEndEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_end"}
|
||||
default:
|
||||
return extensions.SubagentEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// skillCmd installs the kit-extensions skill via the skills.sh CLI (npx skills).
|
||||
// This teaches AI agents how to create Kit extensions with full knowledge of
|
||||
// the extension API, lifecycle events, widgets, tools, commands, and Yaegi constraints.
|
||||
var skillCmd = &cobra.Command{
|
||||
Use: "skill",
|
||||
Short: "Install the Kit extensions skill via skills.sh",
|
||||
Long: `Install the kit-extensions skill that teaches AI agents how to create
|
||||
Kit extensions. Uses the skills.sh CLI (npx skills) to install the skill
|
||||
from the Kit repository.
|
||||
|
||||
The skill provides comprehensive documentation of Kit's extension API including
|
||||
lifecycle events, custom tools, slash commands, widgets, editor interceptors,
|
||||
tool renderers, and critical Yaegi interpreter constraints.
|
||||
|
||||
Example:
|
||||
kit skill`,
|
||||
RunE: runSkill,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(skillCmd)
|
||||
}
|
||||
|
||||
func runSkill(_ *cobra.Command, _ []string) error {
|
||||
npx, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
return fmt.Errorf("npx not found in PATH — install Node.js to use this command: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"skills",
|
||||
"add",
|
||||
"mark3labs/kit",
|
||||
"--skill",
|
||||
"kit-extensions",
|
||||
}
|
||||
|
||||
cmd := exec.Command(npx, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("skills install failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
//go:build ignore
|
||||
|
||||
// Subagent Test Extension — Tests the new first-class subagent API
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /subtest <task> — spawn a blocking subagent and print result
|
||||
// /subbg <task> — spawn a background subagent with live output
|
||||
//
|
||||
// Usage: kit -e examples/extensions/subagent-test.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
latestCtx ext.Context
|
||||
hasCtx bool
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Keep context fresh
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
ctx.PrintInfo(
|
||||
"Subagent Test Extension loaded\n\n" +
|
||||
"/subtest <task> Spawn blocking subagent\n" +
|
||||
"/subbg <task> Spawn background subagent\n\n" +
|
||||
"The LLM can also use the spawn_subagent tool.")
|
||||
})
|
||||
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
// Command: /subtest <task> — blocking subagent
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subtest",
|
||||
Description: "Spawn a blocking subagent: /subtest <task>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
task := strings.TrimSpace(args)
|
||||
if task == "" {
|
||||
return "Usage: /subtest <task>", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Spawning blocking subagent for: %s", task))
|
||||
|
||||
start := time.Now()
|
||||
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
Prompt: task,
|
||||
Timeout: 2 * time.Minute,
|
||||
Blocking: true,
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Spawn error: %v", err), nil
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return "No result returned", nil
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Sprintf("Subagent failed (exit %d) after %ds: %v\n\nPartial output:\n%s",
|
||||
result.ExitCode, int(elapsed.Seconds()), result.Error, truncate(result.Response, 2000)), nil
|
||||
}
|
||||
|
||||
response := fmt.Sprintf("Subagent completed in %ds", int(elapsed.Seconds()))
|
||||
if result.Usage != nil {
|
||||
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
|
||||
}
|
||||
response += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
|
||||
|
||||
return response, nil
|
||||
},
|
||||
})
|
||||
|
||||
// Command: /subbg <task> — background subagent with callbacks
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subbg",
|
||||
Description: "Spawn a background subagent: /subbg <task>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
task := strings.TrimSpace(args)
|
||||
if task == "" {
|
||||
return "Usage: /subbg <task>", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Spawning background subagent for: %s", task))
|
||||
|
||||
start := time.Now()
|
||||
handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
Prompt: task,
|
||||
Timeout: 2 * time.Minute,
|
||||
OnOutput: func(chunk string) {
|
||||
// Live output - could update a widget here
|
||||
fmt.Print(chunk)
|
||||
},
|
||||
OnComplete: func(result ext.SubagentResult) {
|
||||
elapsed := time.Since(start)
|
||||
|
||||
mu.Lock()
|
||||
c := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
c.SendMessage(fmt.Sprintf("Background subagent failed after %ds: %v",
|
||||
int(elapsed.Seconds()), result.Error))
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Background subagent completed in %ds", int(elapsed.Seconds()))
|
||||
if result.Usage != nil {
|
||||
msg += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
|
||||
}
|
||||
msg += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
|
||||
|
||||
c.SendMessage(msg)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Spawn error: %v", err), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Background subagent spawned (ID: %s). Results will be delivered when complete.", handle.ID), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n\n... [truncated]"
|
||||
}
|
||||
@@ -59,6 +59,7 @@ require (
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/coder/acp-go-sdk v0.6.3 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
|
||||
@@ -114,6 +114,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
|
||||
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// Package acpserver implements a Kit-backed ACP (Agent Client Protocol) agent.
|
||||
//
|
||||
// It bridges Kit's LLM execution, tool system, and session management to the
|
||||
// ACP protocol over stdio, allowing ACP clients (such as OpenCode) to drive
|
||||
// Kit as a remote coding agent.
|
||||
package acpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// Version is injected at build time; fallback to "dev".
|
||||
var Version = "dev"
|
||||
|
||||
// Agent implements the acp.Agent interface, delegating to Kit for LLM
|
||||
// execution, tool calls, and session management.
|
||||
type Agent struct {
|
||||
conn *acp.AgentSideConnection
|
||||
registry *sessionRegistry
|
||||
|
||||
// toolCallCounter provides unique IDs for tool calls within a turn.
|
||||
toolCallCounter atomic.Int64
|
||||
}
|
||||
|
||||
// NewAgent creates a new ACP agent backed by Kit.
|
||||
func NewAgent() *Agent {
|
||||
return &Agent{
|
||||
registry: newSessionRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetAgentConnection stores the connection so the agent can send session
|
||||
// updates (streaming, tool calls, etc.) back to the ACP client. This follows
|
||||
// the AgentConnAware duck-typing pattern from the SDK.
|
||||
func (a *Agent) SetAgentConnection(conn *acp.AgentSideConnection) {
|
||||
a.conn = conn
|
||||
}
|
||||
|
||||
// Close shuts down all active sessions.
|
||||
func (a *Agent) Close() {
|
||||
a.registry.closeAll()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acp.Agent interface implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Authenticate handles authentication requests. Kit doesn't require auth for
|
||||
// local stdio usage, so this is a no-op.
|
||||
func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
|
||||
return acp.AuthenticateResponse{}, nil
|
||||
}
|
||||
|
||||
// Initialize negotiates capabilities with the ACP client.
|
||||
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
|
||||
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
|
||||
|
||||
return acp.InitializeResponse{
|
||||
ProtocolVersion: acp.ProtocolVersion(1),
|
||||
AgentCapabilities: acp.AgentCapabilities{
|
||||
LoadSession: true,
|
||||
PromptCapabilities: acp.PromptCapabilities{
|
||||
EmbeddedContext: true,
|
||||
Image: true,
|
||||
},
|
||||
},
|
||||
AgentInfo: &acp.Implementation{
|
||||
Name: "Kit",
|
||||
Version: Version,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewSession creates a new Kit session for the given working directory.
|
||||
func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (acp.NewSessionResponse, error) {
|
||||
cwd := params.Cwd
|
||||
if cwd == "" {
|
||||
return acp.NewSessionResponse{}, acp.NewInvalidParams("cwd is required")
|
||||
}
|
||||
|
||||
log.Debug("acp: new_session", "cwd", cwd)
|
||||
|
||||
sess, err := a.registry.create(ctx, cwd)
|
||||
if err != nil {
|
||||
log.Error("acp: session creation failed", "cwd", cwd, "error", err)
|
||||
return acp.NewSessionResponse{}, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
return acp.NewSessionResponse{
|
||||
SessionId: acp.SessionId(sess.sessionID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Prompt handles the main agent execution. It subscribes to Kit's event bus,
|
||||
// converts events to ACP session updates, and runs the prompt through Kit's
|
||||
// full turn lifecycle (hooks, LLM, tool calls, persistence).
|
||||
func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return acp.PromptResponse{}, acp.NewInvalidParams(
|
||||
fmt.Sprintf("session not found: %s", sessionID),
|
||||
)
|
||||
}
|
||||
|
||||
// Extract text from prompt content blocks.
|
||||
promptText := extractPromptText(params.Prompt)
|
||||
if promptText == "" {
|
||||
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
|
||||
}
|
||||
|
||||
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
|
||||
|
||||
// Create a cancellable context for this prompt turn.
|
||||
promptCtx, cancel := context.WithCancel(ctx)
|
||||
sess.setCancel(cancel)
|
||||
defer sess.clearCancel()
|
||||
|
||||
// Subscribe to Kit events and stream them as ACP session updates.
|
||||
unsub := a.subscribeEvents(promptCtx, sess.kit, params.SessionId)
|
||||
defer unsub()
|
||||
|
||||
// Run the prompt through Kit's full turn lifecycle.
|
||||
_, err := sess.kit.PromptResult(promptCtx, promptText)
|
||||
if err != nil {
|
||||
if promptCtx.Err() != nil {
|
||||
return acp.PromptResponse{
|
||||
StopReason: acp.StopReasonCancelled,
|
||||
}, nil
|
||||
}
|
||||
return acp.PromptResponse{}, fmt.Errorf("prompt failed: %w", err)
|
||||
}
|
||||
|
||||
return acp.PromptResponse{
|
||||
StopReason: acp.StopReasonEndTurn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cancel cancels the ongoing prompt for a session.
|
||||
func (a *Agent) Cancel(_ context.Context, params acp.CancelNotification) error {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return nil // No-op if session doesn't exist.
|
||||
}
|
||||
|
||||
log.Debug("acp: cancel", "session", sessionID)
|
||||
sess.cancelPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSessionMode is a no-op for now — Kit doesn't have built-in session modes.
|
||||
func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) {
|
||||
return acp.SetSessionModeResponse{}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event streaming: Kit events → ACP SessionUpdate notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subscribeEvents subscribes to Kit's event bus and forwards events as ACP
|
||||
// session update notifications to the client.
|
||||
func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.SessionId) func() {
|
||||
return k.Subscribe(func(e kit.Event) {
|
||||
// Don't send updates after the context is cancelled.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var update *acp.SessionUpdate
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
u := acp.UpdateAgentMessageText(ev.Chunk)
|
||||
update = &u
|
||||
|
||||
case kit.ReasoningDeltaEvent:
|
||||
u := acp.UpdateAgentThoughtText(ev.Delta)
|
||||
update = &u
|
||||
|
||||
case kit.ToolCallEvent:
|
||||
tcID := acp.ToolCallId(ev.ToolCallID)
|
||||
if tcID == "" {
|
||||
tcID = acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Add(1)))
|
||||
}
|
||||
u := acp.StartToolCall(tcID, ev.ToolName,
|
||||
acp.WithStartStatus(acp.ToolCallStatusInProgress),
|
||||
acp.WithStartRawInput(parseToolArgs(ev.ToolArgs)),
|
||||
)
|
||||
update = &u
|
||||
|
||||
case kit.ToolResultEvent:
|
||||
tcID := acp.ToolCallId(ev.ToolCallID)
|
||||
if tcID == "" {
|
||||
tcID = acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Load()))
|
||||
}
|
||||
status := acp.ToolCallStatusCompleted
|
||||
if ev.IsError {
|
||||
status = acp.ToolCallStatusFailed
|
||||
}
|
||||
u := acp.UpdateToolCall(tcID,
|
||||
acp.WithUpdateStatus(status),
|
||||
acp.WithUpdateContent([]acp.ToolCallContent{
|
||||
acp.ToolContent(acp.TextBlock(ev.Result)),
|
||||
}),
|
||||
)
|
||||
update = &u
|
||||
|
||||
case kit.ToolCallContentEvent:
|
||||
u := acp.UpdateAgentMessageText(ev.Content)
|
||||
update = &u
|
||||
}
|
||||
|
||||
if update != nil {
|
||||
_ = a.conn.SessionUpdate(ctx, acp.SessionNotification{
|
||||
SessionId: sessionID,
|
||||
Update: *update,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// extractPromptText extracts the concatenated text content from ACP content
|
||||
// blocks. Non-text blocks are ignored for now.
|
||||
func extractPromptText(blocks []acp.ContentBlock) string {
|
||||
var text string
|
||||
for _, block := range blocks {
|
||||
if block.Text != nil {
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += block.Text.Text
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// parseToolArgs attempts to parse a JSON tool args string into a map for
|
||||
// structured display. Falls back to a simple string wrapper.
|
||||
func parseToolArgs(args string) any {
|
||||
if args == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(args), &m); err == nil {
|
||||
return m
|
||||
}
|
||||
return map[string]any{"input": args}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package acpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// acpSession maps an ACP session to a Kit instance with its own tree session.
|
||||
type acpSession struct {
|
||||
kit *kit.Kit
|
||||
cancelFn context.CancelFunc // cancels the current prompt
|
||||
cancelMu sync.Mutex
|
||||
cwd string
|
||||
sessionID string // Kit-generated session ID (from JSONL header)
|
||||
}
|
||||
|
||||
// sessionRegistry is a thread-safe registry of ACP session ID → Kit sessions.
|
||||
type sessionRegistry struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*acpSession // ACP session ID → session
|
||||
}
|
||||
|
||||
func newSessionRegistry() *sessionRegistry {
|
||||
return &sessionRegistry{
|
||||
sessions: make(map[string]*acpSession),
|
||||
}
|
||||
}
|
||||
|
||||
// create creates a new Kit instance with a persisted tree session for the
|
||||
// given working directory. The Kit-generated session ID is used as the ACP
|
||||
// session ID so the mapping is 1:1.
|
||||
func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, error) {
|
||||
kitInstance, err := kit.New(ctx, &kit.Options{
|
||||
SessionDir: cwd,
|
||||
Quiet: true,
|
||||
Streaming: true,
|
||||
})
|
||||
if err != nil {
|
||||
// Provide actionable guidance for provider auth errors, which are
|
||||
// the most common failure mode when running via ACP.
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "API key") || strings.Contains(msg, "credentials") || strings.Contains(msg, "OAuth") {
|
||||
return nil, fmt.Errorf("provider authentication failed: %w — run 'kit auth login <provider>' or set the appropriate environment variable before starting 'kit acp'", err)
|
||||
}
|
||||
return nil, fmt.Errorf("create kit instance: %w", err)
|
||||
}
|
||||
|
||||
sessionID := kitInstance.GetSessionID()
|
||||
if sessionID == "" {
|
||||
_ = kitInstance.Close()
|
||||
return nil, fmt.Errorf("kit instance has no session ID")
|
||||
}
|
||||
|
||||
// Wire extension context with headless implementations so extensions
|
||||
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
|
||||
// become no-ops or return cancelled; all data/model/tool APIs work
|
||||
// identically to interactive mode.
|
||||
if kitInstance.HasExtensions() {
|
||||
kitInstance.SetExtensionContext(extensions.Context{
|
||||
SessionID: sessionID,
|
||||
CWD: cwd,
|
||||
Model: kitInstance.GetModelString(),
|
||||
Interactive: false,
|
||||
|
||||
// Output — route through structured logger.
|
||||
Print: func(text string) { log.Debug("extension: print", "text", text) },
|
||||
PrintInfo: func(text string) { log.Info("extension: info", "text", text) },
|
||||
PrintError: func(text string) { log.Error("extension: error", "text", text) },
|
||||
PrintBlock: func(opts extensions.PrintBlockOpts) {
|
||||
log.Info("extension: block", "subtitle", opts.Subtitle, "text", opts.Text)
|
||||
},
|
||||
|
||||
// Message injection — no-ops for now; ACP clients drive prompts.
|
||||
SendMessage: func(string) {},
|
||||
CancelAndSend: func(string) {},
|
||||
Exit: func() {},
|
||||
|
||||
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
|
||||
SetWidget: func(extensions.WidgetConfig) {},
|
||||
RemoveWidget: func(string) {},
|
||||
SetHeader: func(extensions.HeaderFooterConfig) {},
|
||||
RemoveHeader: func() {},
|
||||
SetFooter: func(extensions.HeaderFooterConfig) {},
|
||||
RemoveFooter: func() {},
|
||||
SetEditor: func(extensions.EditorConfig) {},
|
||||
ResetEditor: func() {},
|
||||
SetEditorText: func(string) {},
|
||||
SetUIVisibility: func(extensions.UIVisibility) {},
|
||||
SetStatus: func(string, string, int) {},
|
||||
RemoveStatus: func(string) {},
|
||||
|
||||
// Interactive prompts — return cancelled (no user to prompt).
|
||||
PromptSelect: func(extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
return extensions.PromptSelectResult{Cancelled: true}
|
||||
},
|
||||
PromptConfirm: func(extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
|
||||
return extensions.PromptConfirmResult{Cancelled: true}
|
||||
},
|
||||
PromptInput: func(extensions.PromptInputConfig) extensions.PromptInputResult {
|
||||
return extensions.PromptInputResult{Cancelled: true}
|
||||
},
|
||||
ShowOverlay: func(extensions.OverlayConfig) extensions.OverlayResult {
|
||||
return extensions.OverlayResult{Cancelled: true, Index: -1}
|
||||
},
|
||||
SuspendTUI: func(callback func()) error { callback(); return nil },
|
||||
|
||||
// Data access — delegate to Kit instance.
|
||||
GetContextStats: func() extensions.ContextStats {
|
||||
s := kitInstance.GetContextStats()
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: s.EstimatedTokens,
|
||||
ContextLimit: s.ContextLimit,
|
||||
UsagePercent: s.UsagePercent,
|
||||
MessageCount: s.MessageCount,
|
||||
}
|
||||
},
|
||||
GetMessages: func() []extensions.SessionMessage { return kitInstance.GetSessionMessages() },
|
||||
GetSessionPath: func() string { return kitInstance.GetSessionFilePath() },
|
||||
AppendEntry: func(entryType, data string) (string, error) {
|
||||
return kitInstance.AppendExtensionEntry(entryType, data)
|
||||
},
|
||||
GetEntries: func(entryType string) []extensions.ExtensionEntry {
|
||||
return kitInstance.GetExtensionEntries(entryType)
|
||||
},
|
||||
|
||||
// Options, model, and tool management.
|
||||
GetOption: func(name string) string { return kitInstance.GetExtensionOption(name) },
|
||||
SetOption: func(name, value string) { kitInstance.SetExtensionOption(name, value) },
|
||||
SetModel: func(modelString string) error {
|
||||
previousModel := kitInstance.GetExtensionContext().Model
|
||||
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
|
||||
return err
|
||||
}
|
||||
kitInstance.UpdateExtensionContextModel(modelString)
|
||||
kitInstance.EmitModelChange(modelString, previousModel, "extension")
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry { return kitInstance.GetAvailableModels() },
|
||||
EmitCustomEvent: func(name, data string) { kitInstance.EmitExtensionCustomEvent(name, data) },
|
||||
GetAllTools: func() []extensions.ToolInfo { return kitInstance.GetExtensionToolInfos() },
|
||||
SetActiveTools: func(names []string) { kitInstance.SetExtensionActiveTools(names) },
|
||||
|
||||
// LLM completions and subagents.
|
||||
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return kitInstance.ExecuteCompletion(context.Background(), req)
|
||||
},
|
||||
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
sdkCfg := kit.SubagentConfig{
|
||||
Prompt: config.Prompt,
|
||||
Model: config.Model,
|
||||
SystemPrompt: config.SystemPrompt,
|
||||
Timeout: config.Timeout,
|
||||
NoSession: config.NoSession,
|
||||
}
|
||||
if config.OnEvent != nil {
|
||||
sdkCfg.OnEvent = func(e kit.Event) {
|
||||
se := sdkEventToSubagentEvent(e)
|
||||
if se.Type != "" {
|
||||
config.OnEvent(se)
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := kitInstance.Subagent(context.Background(), sdkCfg)
|
||||
if result == nil {
|
||||
return nil, &extensions.SubagentResult{Error: err}, err
|
||||
}
|
||||
extResult := &extensions.SubagentResult{
|
||||
Response: result.Response,
|
||||
Error: result.Error,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
extResult.Usage = &extensions.SubagentUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
|
||||
// Render — fall back to logging.
|
||||
RenderMessage: func(name, content string) {
|
||||
renderer := kitInstance.GetExtensionMessageRenderer(name)
|
||||
if renderer != nil && renderer.Render != nil {
|
||||
content = renderer.Render(content, 80)
|
||||
}
|
||||
log.Info("extension: message", "renderer", name, "content", content)
|
||||
},
|
||||
ReloadExtensions: func() error { return kitInstance.ReloadExtensions() },
|
||||
})
|
||||
kitInstance.EmitSessionStart()
|
||||
}
|
||||
|
||||
sess := &acpSession{
|
||||
kit: kitInstance,
|
||||
cwd: cwd,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.sessions[sessionID] = sess
|
||||
r.mu.Unlock()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// get retrieves a session by ACP session ID.
|
||||
func (r *sessionRegistry) get(sessionID string) (*acpSession, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
s, ok := r.sessions[sessionID]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// closeAll closes all sessions.
|
||||
func (r *sessionRegistry) closeAll() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id, sess := range r.sessions {
|
||||
if sess.kit != nil {
|
||||
_ = sess.kit.Close()
|
||||
}
|
||||
delete(r.sessions, id)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelPrompt cancels the current prompt for a session, if any.
|
||||
func (s *acpSession) cancelPrompt() {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
if s.cancelFn != nil {
|
||||
s.cancelFn()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// setCancel stores a cancel function for the current prompt.
|
||||
func (s *acpSession) setCancel(cancel context.CancelFunc) {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = cancel
|
||||
}
|
||||
|
||||
// clearCancel clears the stored cancel function (called when prompt completes).
|
||||
func (s *acpSession) clearCancel() {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
|
||||
// sdkEventToSubagentEvent converts an SDK event to an extension SubagentEvent.
|
||||
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
|
||||
case kit.ReasoningDeltaEvent:
|
||||
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
|
||||
case kit.ToolCallEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_call", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
|
||||
}
|
||||
case kit.ToolExecutionStartEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolExecutionEndEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolResultEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_result", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
ToolResult: ev.Result, IsError: ev.IsError,
|
||||
}
|
||||
case kit.TurnStartEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_start"}
|
||||
case kit.TurnEndEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_end"}
|
||||
default:
|
||||
return extensions.SubagentEvent{}
|
||||
}
|
||||
}
|
||||
+32
-18
@@ -41,13 +41,15 @@ type AgentConfig struct {
|
||||
}
|
||||
|
||||
// ToolCallHandler is a function type for handling tool calls as they happen.
|
||||
type ToolCallHandler func(toolName, toolArgs string)
|
||||
type ToolCallHandler func(toolCallID, toolName, toolArgs string)
|
||||
|
||||
// ToolExecutionHandler is a function type for handling tool execution start/end events.
|
||||
type ToolExecutionHandler func(toolName string, isStarting bool)
|
||||
type ToolExecutionHandler func(toolCallID, toolName, toolArgs string, isStarting bool)
|
||||
|
||||
// ToolResultHandler is a function type for handling tool results.
|
||||
type ToolResultHandler func(toolName, toolArgs, result string, isError bool)
|
||||
// The metadata parameter carries optional structured data (e.g. file diff
|
||||
// info) from the tool execution, JSON-encoded. It may be empty.
|
||||
type ToolResultHandler func(toolCallID, toolName, toolArgs, result, metadata string, isError bool)
|
||||
|
||||
// ResponseHandler is a function type for handling LLM responses.
|
||||
type ResponseHandler func(content string)
|
||||
@@ -90,6 +92,8 @@ type GenerateWithLoopResult struct {
|
||||
Messages []message.Message
|
||||
// TotalUsage contains aggregate token usage across all steps
|
||||
TotalUsage fantasy.Usage
|
||||
// StopReason is the LLM provider's finish reason for the final response.
|
||||
StopReason string
|
||||
}
|
||||
|
||||
// NewAgent creates a new Agent with core tools and optional MCP tool integration.
|
||||
@@ -228,8 +232,10 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Fantasy requires the current user input as Prompt, with prior messages as history.
|
||||
// Extract the last user message text as the prompt, and pass everything before it as Messages.
|
||||
prompt, history := splitPromptAndHistory(messages)
|
||||
// Extract the last user message text and files as the prompt, and pass everything
|
||||
// before it as Messages. Files (e.g. clipboard images) are passed via the Files
|
||||
// field so Fantasy includes them in the API request.
|
||||
prompt, files, history := splitPromptAndHistory(messages)
|
||||
|
||||
// Track current tool call info for callbacks
|
||||
var currentToolName string
|
||||
@@ -246,6 +252,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Use fantasy's streaming agent
|
||||
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: prompt,
|
||||
Files: files,
|
||||
Messages: history,
|
||||
|
||||
// Reasoning/thinking streaming callback
|
||||
@@ -280,12 +287,12 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
|
||||
// Notify about the tool call
|
||||
if onToolCall != nil {
|
||||
onToolCall(tc.ToolName, tc.Input)
|
||||
onToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
|
||||
}
|
||||
|
||||
// Notify tool execution starting
|
||||
if onToolExecution != nil {
|
||||
onToolExecution(tc.ToolName, true)
|
||||
onToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -298,13 +305,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
}
|
||||
// Notify tool execution finished
|
||||
if onToolExecution != nil {
|
||||
onToolExecution(tr.ToolName, false)
|
||||
onToolExecution(tr.ToolCallID, tr.ToolName, currentToolArgs, false)
|
||||
}
|
||||
|
||||
if onToolResult != nil {
|
||||
// Extract result text and error status
|
||||
resultText, isError := extractToolResultText(tr)
|
||||
onToolResult(tr.ToolName, currentToolArgs, resultText, isError)
|
||||
onToolResult(tr.ToolCallID, tr.ToolName, currentToolArgs, resultText, tr.ClientMetadata, isError)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -340,6 +347,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Non-streaming path with no callbacks — use the simpler Generate call.
|
||||
result, err := a.fantasyAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: prompt,
|
||||
Files: files,
|
||||
Messages: history,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -360,27 +368,32 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// and returns everything before it as conversation history. Fantasy's agent
|
||||
// requires the current turn's input as Prompt (string), with prior messages
|
||||
// passed separately as Messages (history).
|
||||
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Message) {
|
||||
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.FilePart, []fantasy.Message) {
|
||||
if len(messages) == 0 {
|
||||
return "", nil
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Walk backwards to find the last user message
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == fantasy.MessageRoleUser {
|
||||
// Extract text from the user message parts
|
||||
// Extract text and file parts from the user message
|
||||
var prompt string
|
||||
var files []fantasy.FilePart
|
||||
for _, part := range messages[i].Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
prompt = tp.Text
|
||||
break
|
||||
switch p := part.(type) {
|
||||
case fantasy.TextPart:
|
||||
if prompt == "" {
|
||||
prompt = p.Text
|
||||
}
|
||||
case fantasy.FilePart:
|
||||
files = append(files, p)
|
||||
}
|
||||
}
|
||||
// History is everything except this last user message
|
||||
history := make([]fantasy.Message, 0, len(messages)-1)
|
||||
history = append(history, messages[:i]...)
|
||||
history = append(history, messages[i+1:]...)
|
||||
return prompt, history
|
||||
return prompt, files, history
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,11 +401,11 @@ func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Messag
|
||||
last := messages[len(messages)-1]
|
||||
for _, part := range last.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
return tp.Text, messages[:len(messages)-1]
|
||||
return tp.Text, nil, messages[:len(messages)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return "", messages
|
||||
return "", nil, messages
|
||||
}
|
||||
|
||||
// convertAgentResult converts a fantasy AgentResult to our GenerateWithLoopResult.
|
||||
@@ -417,6 +430,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
|
||||
ConversationMessages: allFantasyMessages,
|
||||
Messages: allMessages,
|
||||
TotalUsage: result.TotalUsage,
|
||||
StopReason: string(result.Response.FinishReason),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -532,14 +532,14 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
unsubs = append(unsubs, k.Subscribe(func(e kit.Event) {
|
||||
switch ev := e.(type) {
|
||||
case kit.ToolCallEvent:
|
||||
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
sendFn(ToolCallStartedEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
case kit.ToolExecutionStartEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
|
||||
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
|
||||
case kit.ToolExecutionEndEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
|
||||
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, IsStarting: false})
|
||||
case kit.ToolResultEvent:
|
||||
sendFn(ToolResultEvent{
|
||||
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
Result: ev.Result, IsError: ev.IsError,
|
||||
})
|
||||
case kit.ToolCallContentEvent:
|
||||
|
||||
@@ -19,6 +19,8 @@ type ReasoningChunkEvent struct {
|
||||
// ToolCallStartedEvent is sent when a tool call has been parsed and is about to execute.
|
||||
// It carries the tool name and its arguments for display purposes.
|
||||
type ToolCallStartedEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool being called.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments for the tool call.
|
||||
@@ -28,14 +30,20 @@ type ToolCallStartedEvent struct {
|
||||
// ToolExecutionEvent is sent when a tool starts or finishes executing.
|
||||
// The IsStarting flag distinguishes between the start and end of execution.
|
||||
type ToolExecutionEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool being executed.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments for the tool call (only set when IsStarting is true).
|
||||
ToolArgs string
|
||||
// IsStarting is true when execution is beginning, false when it is complete.
|
||||
IsStarting bool
|
||||
}
|
||||
|
||||
// ToolResultEvent is sent after a tool execution completes with its result.
|
||||
type ToolResultEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool that was executed.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments that were passed to the tool.
|
||||
|
||||
@@ -51,6 +51,7 @@ func TestCredentialManager(t *testing.T) {
|
||||
}
|
||||
if creds == nil {
|
||||
t.Fatal("Expected credentials to be returned")
|
||||
return
|
||||
}
|
||||
if creds.APIKey != testAPIKey {
|
||||
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
|
||||
@@ -236,6 +237,7 @@ func TestCredentialStorePersistence(t *testing.T) {
|
||||
}
|
||||
if creds == nil {
|
||||
t.Fatal("Expected credentials to persist")
|
||||
return
|
||||
}
|
||||
if creds.APIKey != testAPIKey {
|
||||
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
|
||||
|
||||
+21
-3
@@ -76,13 +76,15 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
// If no exact match, try fuzzy matching
|
||||
if count == 0 {
|
||||
if idx, matchLen := fuzzyMatch(normalized, normalizedOld); idx >= 0 {
|
||||
// Apply fuzzy match
|
||||
// Apply fuzzy match — the matched text is the original content slice
|
||||
matchedText := normalized[idx : idx+matchLen]
|
||||
newContent := normalized[:idx] + args.NewText + normalized[idx+matchLen:]
|
||||
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
|
||||
}
|
||||
diff := generateDiff(absPath, normalized, newContent, idx)
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)), nil
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff))
|
||||
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, matchedText, args.NewText)), nil
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("old_text not found in %s", args.Path)), nil
|
||||
}
|
||||
@@ -100,7 +102,23 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
|
||||
idx := strings.Index(normalized, normalizedOld)
|
||||
diff := generateDiff(absPath, normalized, newContent, idx)
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)), nil
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff))
|
||||
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, normalizedOld, args.NewText)), nil
|
||||
}
|
||||
|
||||
// editDiffMeta builds the structured metadata attached to edit tool responses.
|
||||
func editDiffMeta(path, oldText, newText string) map[string]any {
|
||||
return map[string]any{
|
||||
"file_diffs": []map[string]any{{
|
||||
"path": path,
|
||||
"additions": strings.Count(newText, "\n") + 1,
|
||||
"deletions": strings.Count(oldText, "\n") + 1,
|
||||
"diff_blocks": []map[string]any{{
|
||||
"old_text": oldText,
|
||||
"new_text": newText,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// fuzzyMatch tries to find old_text with relaxed matching:
|
||||
|
||||
@@ -39,6 +39,7 @@ func NewFindTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeFind(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -59,6 +59,7 @@ func NewGrepTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeGrep(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeLs(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -38,6 +38,7 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeRead(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
const defaultSubagentTimeout = 5 * time.Minute
|
||||
const maxSubagentTimeout = 30 * time.Minute
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context-based subagent spawner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentSpawnResult carries the outcome of an in-process subagent spawn.
|
||||
type SubagentSpawnResult struct {
|
||||
Response string
|
||||
Error error
|
||||
SessionID string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// SubagentSpawnFunc is a callback that spawns an in-process subagent. The
|
||||
// parent Kit instance injects this into the context so the core tool can
|
||||
// call back without importing pkg/kit (which would create a cycle).
|
||||
type SubagentSpawnFunc func(ctx context.Context, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
|
||||
|
||||
type subagentCtxKey struct{}
|
||||
|
||||
// WithSubagentSpawner stores a spawn function in the context so that the
|
||||
// spawn_subagent core tool can create in-process subagents.
|
||||
func WithSubagentSpawner(ctx context.Context, fn SubagentSpawnFunc) context.Context {
|
||||
return context.WithValue(ctx, subagentCtxKey{}, fn)
|
||||
}
|
||||
|
||||
// getSubagentSpawner retrieves the spawn function from the context.
|
||||
func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc {
|
||||
if fn, ok := ctx.Value(subagentCtxKey{}).(SubagentSpawnFunc); ok {
|
||||
return fn
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// spawn_subagent tool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type subagentArgs struct {
|
||||
Task string `json:"task"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// NewSubagentTool creates the spawn_subagent core tool.
|
||||
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
return &coreTool{
|
||||
info: fantasy.ToolInfo{
|
||||
Name: "spawn_subagent",
|
||||
Description: `Spawn a subagent to perform a task autonomously.
|
||||
|
||||
The subagent runs as a separate in-process Kit instance with full tool access
|
||||
(except spawning further subagents). Use this to:
|
||||
- Delegate independent subtasks that can run in parallel
|
||||
- Perform research or analysis without blocking your main work
|
||||
- Execute tasks that benefit from a fresh context window
|
||||
|
||||
The subagent result is returned when it completes. For long-running tasks,
|
||||
consider breaking them into smaller focused subtasks.
|
||||
|
||||
Example use cases:
|
||||
- "Research the authentication patterns in this codebase"
|
||||
- "Write unit tests for the UserService class"
|
||||
- "Analyze the performance bottlenecks in the database queries"`,
|
||||
Parameters: map[string]any{
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The complete task description for the subagent to perform",
|
||||
},
|
||||
"model": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
|
||||
},
|
||||
"system_prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional system prompt for domain-specific guidance",
|
||||
},
|
||||
"timeout_seconds": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
|
||||
},
|
||||
},
|
||||
Required: []string{"task"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeSubagent(ctx, call)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var args subagentArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("task parameter is required"), nil
|
||||
}
|
||||
if args.Task == "" {
|
||||
return fantasy.NewTextErrorResponse("task parameter is required"), nil
|
||||
}
|
||||
|
||||
// Determine timeout.
|
||||
timeout := defaultSubagentTimeout
|
||||
if args.TimeoutSeconds > 0 {
|
||||
timeout = min(time.Duration(args.TimeoutSeconds)*time.Second, maxSubagentTimeout)
|
||||
}
|
||||
|
||||
// Retrieve in-process spawner from context.
|
||||
spawner := getSubagentSpawner(ctx)
|
||||
if spawner == nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
"Error: subagent spawner not available. " +
|
||||
"Ensure Kit is initialized with subagent support.",
|
||||
), fmt.Errorf("no subagent spawner in context")
|
||||
}
|
||||
|
||||
// Spawn in-process subagent.
|
||||
result, err := spawner(ctx, args.Task, args.Model, args.SystemPrompt, timeout)
|
||||
if err != nil || result.Error != nil {
|
||||
spawnErr := err
|
||||
if spawnErr == nil {
|
||||
spawnErr = result.Error
|
||||
}
|
||||
response := fmt.Sprintf("Subagent failed after %ds.\n\nError: %v",
|
||||
int(result.Elapsed.Seconds()), spawnErr)
|
||||
if result.Response != "" {
|
||||
response += fmt.Sprintf("\n\nPartial output:\n%s", truncateResponse(result.Response, 8000))
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(response), nil
|
||||
}
|
||||
|
||||
// Build successful response.
|
||||
response := fmt.Sprintf("Subagent completed successfully in %ds.", int(result.Elapsed.Seconds()))
|
||||
if result.InputTokens > 0 || result.OutputTokens > 0 {
|
||||
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.InputTokens, result.OutputTokens)
|
||||
}
|
||||
response += fmt.Sprintf("\n\nResult:\n%s", truncateResponse(result.Response, 12000))
|
||||
|
||||
resp := fantasy.NewTextResponse(response)
|
||||
|
||||
// Attach subagent session ID as metadata when available.
|
||||
if result.SessionID != "" {
|
||||
resp = fantasy.WithResponseMetadata(resp, map[string]any{
|
||||
"subagent_session_id": result.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// truncateResponse limits the response length to avoid overwhelming context windows.
|
||||
func truncateResponse(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "\n\n... [truncated — " + fmt.Sprintf("%d", len(s)-maxLen) + " bytes omitted]"
|
||||
}
|
||||
@@ -86,8 +86,9 @@ func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
}
|
||||
}
|
||||
|
||||
// AllTools returns all available core tools.
|
||||
func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
// SubagentTools returns all core tools except spawn_subagent. This prevents
|
||||
// infinite recursion when a subagent is itself a Kit instance.
|
||||
func SubagentTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewBashTool(opts...),
|
||||
NewReadTool(opts...),
|
||||
@@ -98,3 +99,8 @@ func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
NewLsTool(opts...),
|
||||
}
|
||||
}
|
||||
|
||||
// AllTools returns all available core tools.
|
||||
func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return append(SubagentTools(opts...), NewSubagentTool(opts...))
|
||||
}
|
||||
|
||||
+32
-1
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
@@ -53,6 +54,14 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (f
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
|
||||
}
|
||||
|
||||
// Read existing content before writing (for diff metadata).
|
||||
var beforeContent string
|
||||
isNew := true
|
||||
if existing, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
beforeContent = string(existing)
|
||||
isNew = false
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
@@ -63,5 +72,27 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (f
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
|
||||
}
|
||||
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path)), nil
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path))
|
||||
return fantasy.WithResponseMetadata(resp, writeDiffMeta(absPath, beforeContent, args.Content, isNew)), nil
|
||||
}
|
||||
|
||||
// writeDiffMeta builds the structured metadata attached to write tool responses.
|
||||
func writeDiffMeta(path, beforeContent, afterContent string, isNew bool) map[string]any {
|
||||
additions := strings.Count(afterContent, "\n") + 1
|
||||
deletions := 0
|
||||
if !isNew {
|
||||
deletions = strings.Count(beforeContent, "\n") + 1
|
||||
}
|
||||
return map[string]any{
|
||||
"file_diffs": []map[string]any{{
|
||||
"path": path,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"is_new": isNew,
|
||||
"diff_blocks": []map[string]any{{
|
||||
"old_text": beforeContent,
|
||||
"new_text": afterContent,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +491,41 @@ type Context struct {
|
||||
// },
|
||||
// })
|
||||
ReloadExtensions func() error
|
||||
|
||||
// SpawnSubagent spawns a child Kit instance to perform a task autonomously.
|
||||
// The subagent runs as a separate subprocess with full tool access but
|
||||
// isolated session and extensions (--no-session --no-extensions).
|
||||
//
|
||||
// When config.Blocking is true, blocks until completion and returns the
|
||||
// result directly (handle is nil). When false, returns immediately with
|
||||
// a handle for monitoring/cancellation.
|
||||
//
|
||||
// Example — blocking call:
|
||||
//
|
||||
// _, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
// Prompt: "Research authentication patterns in this codebase",
|
||||
// Blocking: true,
|
||||
// Timeout: 2 * time.Minute,
|
||||
// })
|
||||
// if err != nil {
|
||||
// ctx.PrintError("spawn failed: " + err.Error())
|
||||
// return
|
||||
// }
|
||||
// ctx.PrintInfo("Subagent result:\n" + result.Response)
|
||||
//
|
||||
// Example — background spawn with callbacks:
|
||||
//
|
||||
// handle, _, _ := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
// Prompt: "Write unit tests for UserService",
|
||||
// OnOutput: func(chunk string) {
|
||||
// // Live output streaming
|
||||
// },
|
||||
// OnComplete: func(result ext.SubagentResult) {
|
||||
// ctx.SendMessage("Subagent finished:\n" + result.Response)
|
||||
// },
|
||||
// })
|
||||
// // handle.Kill() to cancel, handle.Wait() to block
|
||||
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1397,7 +1432,9 @@ type EditorConfig struct {
|
||||
type ToolCallEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
Input string // JSON-encoded tool parameters
|
||||
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
|
||||
Input string // JSON-encoded tool parameters
|
||||
ParsedArgs map[string]any // Pre-parsed arguments for convenience (nil on parse failure)
|
||||
// Source indicates who initiated the tool call.
|
||||
// Currently always "llm" (all tool calls originate from the LLM agent loop).
|
||||
// Future user-initiated tool features may set this to "user".
|
||||
@@ -1416,24 +1453,31 @@ func (ToolCallResult) isResult() {}
|
||||
|
||||
// ToolExecutionStartEvent fires when a tool begins executing.
|
||||
type ToolExecutionStartEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
}
|
||||
|
||||
func (e ToolExecutionStartEvent) Type() EventType { return ToolExecutionStart }
|
||||
|
||||
// ToolExecutionEndEvent fires when a tool finishes executing.
|
||||
type ToolExecutionEndEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
}
|
||||
|
||||
func (e ToolExecutionEndEvent) Type() EventType { return ToolExecutionEnd }
|
||||
|
||||
// ToolResultEvent fires after tool execution with the output.
|
||||
type ToolResultEvent struct {
|
||||
ToolName string
|
||||
Input string
|
||||
Content string
|
||||
IsError bool
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
Input string
|
||||
Content string
|
||||
IsError bool
|
||||
Metadata string // Optional JSON-encoded structured metadata (e.g. file diffs)
|
||||
}
|
||||
|
||||
func (e ToolResultEvent) Type() EventType { return ToolResult }
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// Package extensions provides subagent spawning capabilities for Kit extensions.
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentConfig configures a subagent spawn.
|
||||
type SubagentConfig struct {
|
||||
// Prompt is the task/instruction for the subagent (required).
|
||||
Prompt string
|
||||
|
||||
// Model overrides the parent's model (e.g. "anthropic/claude-haiku-3-5-20241022").
|
||||
// Empty string uses the parent's current model.
|
||||
Model string
|
||||
|
||||
// SystemPrompt provides domain-specific instructions.
|
||||
// Empty string uses the default system prompt.
|
||||
SystemPrompt string
|
||||
|
||||
// Timeout limits execution time. Zero means 5 minute default.
|
||||
Timeout time.Duration
|
||||
|
||||
// OnOutput streams stderr output chunks as the subagent runs.
|
||||
// Called from a goroutine; must be safe for concurrent use.
|
||||
OnOutput func(chunk string)
|
||||
|
||||
// OnEvent receives real-time events from the subagent's execution:
|
||||
// text chunks, tool calls, tool results, reasoning deltas, etc.
|
||||
// Called synchronously from the subagent's event loop.
|
||||
OnEvent func(SubagentEvent)
|
||||
|
||||
// OnComplete is called when the subagent finishes (success or error).
|
||||
// Called from a goroutine; must be safe for concurrent use.
|
||||
OnComplete func(result SubagentResult)
|
||||
|
||||
// Blocking, when true, makes SpawnSubagent wait for completion and
|
||||
// return the result directly. When false (default), spawns in background
|
||||
// and returns immediately with a handle.
|
||||
Blocking bool
|
||||
|
||||
// NoSession, when true, runs the subagent without persisting a session
|
||||
// file. By default (false), subagent sessions are persisted so they can
|
||||
// be loaded for replay/inspection. Set to true for ephemeral tasks
|
||||
// where session history is not needed.
|
||||
NoSession bool
|
||||
|
||||
// ParentSessionID links the subagent's session to the parent (optional).
|
||||
// When set, the subagent's session header includes a parent reference
|
||||
// so viewers can navigate the session tree.
|
||||
ParentSessionID string
|
||||
}
|
||||
|
||||
// SubagentEvent carries a real-time event from a running subagent. Extensions
|
||||
// use the Type field to determine what happened and read the relevant fields.
|
||||
// This is a concrete struct (not an interface) for Yaegi compatibility.
|
||||
type SubagentEvent struct {
|
||||
// Type identifies the event: "text", "reasoning", "tool_call",
|
||||
// "tool_result", "tool_execution_start", "tool_execution_end",
|
||||
// "turn_start", "turn_end".
|
||||
Type string
|
||||
|
||||
// Content carries text for "text" and "reasoning" events.
|
||||
Content string
|
||||
|
||||
// ToolCallID is set on tool_call, tool_result, tool_execution_start,
|
||||
// and tool_execution_end events.
|
||||
ToolCallID string
|
||||
// ToolName is set on tool-related events.
|
||||
ToolName string
|
||||
// ToolKind is set on tool-related events.
|
||||
ToolKind string
|
||||
// ToolArgs is set on tool_call events (JSON-encoded).
|
||||
ToolArgs string
|
||||
// ToolResult is set on tool_result events.
|
||||
ToolResult string
|
||||
// IsError is set on tool_result events.
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// SubagentResult contains the outcome of a subagent execution.
|
||||
type SubagentResult struct {
|
||||
// Response is the subagent's final text response.
|
||||
Response string
|
||||
|
||||
// Error is set if the subagent failed (nil on success).
|
||||
Error error
|
||||
|
||||
// ExitCode is the subprocess exit code (0 = success).
|
||||
ExitCode int
|
||||
|
||||
// Elapsed is the total execution time.
|
||||
Elapsed time.Duration
|
||||
|
||||
// Usage contains token usage if available.
|
||||
Usage *SubagentUsage
|
||||
|
||||
// SessionID is the subagent's session identifier, if available.
|
||||
// Populated when the subagent persists its session (requires running
|
||||
// without --no-session). Empty for ephemeral sessions.
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// SubagentUsage contains token usage from the subagent's run.
|
||||
type SubagentUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
}
|
||||
|
||||
// SubagentHandle provides control over a running subagent.
|
||||
type SubagentHandle struct {
|
||||
// ID is a unique identifier for this subagent instance.
|
||||
ID string
|
||||
|
||||
proc *os.Process
|
||||
done chan struct{}
|
||||
result *SubagentResult
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Kill terminates the subagent process.
|
||||
func (h *SubagentHandle) Kill() error {
|
||||
h.mu.Lock()
|
||||
proc := h.proc
|
||||
h.mu.Unlock()
|
||||
if proc != nil {
|
||||
return proc.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the subagent completes and returns the result.
|
||||
func (h *SubagentHandle) Wait() SubagentResult {
|
||||
<-h.done
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.result != nil {
|
||||
return *h.result
|
||||
}
|
||||
return SubagentResult{Error: fmt.Errorf("subagent completed without result")}
|
||||
}
|
||||
|
||||
// Done returns a channel that closes when the subagent completes.
|
||||
func (h *SubagentHandle) Done() <-chan struct{} {
|
||||
return h.done
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subagentJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type subagentJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
var subagentCounter uint64
|
||||
|
||||
func generateSubagentID() string {
|
||||
n := atomic.AddUint64(&subagentCounter, 1)
|
||||
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
|
||||
}
|
||||
|
||||
func findKitBinary() string {
|
||||
// Try the current process executable first.
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
// Fall back to PATH lookup.
|
||||
if p, err := exec.LookPath("kit"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "kit"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpawnSubagent implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SpawnSubagent spawns a child Kit instance to perform a task.
|
||||
//
|
||||
// When config.Blocking is true, blocks until completion and returns the result
|
||||
// directly (handle is nil). When false, returns immediately with a handle for
|
||||
// monitoring/cancellation.
|
||||
//
|
||||
// The subagent runs with --json --no-session --no-extensions flags by default,
|
||||
// ensuring isolation from the parent's extensions and session state.
|
||||
func SpawnSubagent(cfg SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
|
||||
if cfg.Prompt == "" {
|
||||
return nil, nil, fmt.Errorf("prompt is required")
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
kitBinary := findKitBinary()
|
||||
|
||||
// Build subprocess arguments.
|
||||
args := []string{
|
||||
"--json",
|
||||
"--no-extensions",
|
||||
}
|
||||
if cfg.NoSession {
|
||||
args = append(args, "--no-session")
|
||||
}
|
||||
if cfg.Model != "" {
|
||||
args = append(args, "--model", cfg.Model)
|
||||
}
|
||||
|
||||
// Handle system prompt - write to temp file if provided.
|
||||
var tmpFile *os.File
|
||||
if cfg.SystemPrompt != "" {
|
||||
var err error
|
||||
tmpFile, err = os.CreateTemp("", "kit-subagent-*.txt")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString(cfg.SystemPrompt); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
return nil, nil, fmt.Errorf("write system prompt: %w", err)
|
||||
}
|
||||
_ = tmpFile.Close()
|
||||
args = append(args, "--system-prompt", tmpFile.Name())
|
||||
}
|
||||
|
||||
// Add the prompt as a positional argument.
|
||||
args = append(args, cfg.Prompt)
|
||||
|
||||
// Create command with timeout context.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
cmd := exec.CommandContext(ctx, kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
handle := &SubagentHandle{
|
||||
ID: generateSubagentID(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start the subprocess.
|
||||
start := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("start subprocess: %w", err)
|
||||
}
|
||||
|
||||
handle.mu.Lock()
|
||||
handle.proc = cmd.Process
|
||||
handle.mu.Unlock()
|
||||
|
||||
// Run the subprocess monitoring in a goroutine.
|
||||
go func() {
|
||||
defer close(handle.done)
|
||||
defer cancel()
|
||||
if tmpFile != nil {
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var stdoutBuf strings.Builder
|
||||
|
||||
// Read stderr (live output).
|
||||
wg.Go(func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if cfg.OnOutput != nil && strings.TrimSpace(line) != "" {
|
||||
cfg.OnOutput(line + "\n")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Read stdout (JSON output).
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
stdoutBuf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Build result.
|
||||
result := SubagentResult{Elapsed: elapsed}
|
||||
if waitErr != nil {
|
||||
result.Error = waitErr
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON output.
|
||||
raw := strings.TrimSpace(stdoutBuf.String())
|
||||
var parsed subagentJSONOutput
|
||||
if raw != "" && json.Unmarshal([]byte(raw), &parsed) == nil {
|
||||
result.Response = parsed.Response
|
||||
result.SessionID = parsed.SessionID
|
||||
if parsed.Usage != nil {
|
||||
result.Usage = &SubagentUsage{
|
||||
InputTokens: parsed.Usage.InputTokens,
|
||||
OutputTokens: parsed.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use raw stdout.
|
||||
result.Response = raw
|
||||
}
|
||||
|
||||
handle.mu.Lock()
|
||||
handle.result = &result
|
||||
handle.proc = nil
|
||||
handle.mu.Unlock()
|
||||
|
||||
if cfg.OnComplete != nil {
|
||||
cfg.OnComplete(result)
|
||||
}
|
||||
}()
|
||||
|
||||
if cfg.Blocking {
|
||||
// Wait for completion and return result directly.
|
||||
<-handle.done
|
||||
handle.mu.Lock()
|
||||
r := handle.result
|
||||
handle.mu.Unlock()
|
||||
return nil, r, nil
|
||||
}
|
||||
|
||||
return handle, nil, nil
|
||||
}
|
||||
@@ -110,6 +110,13 @@ func Symbols() interp.Exports {
|
||||
"BeforeCompactEvent": reflect.ValueOf((*BeforeCompactEvent)(nil)),
|
||||
"BeforeCompactResult": reflect.ValueOf((*BeforeCompactResult)(nil)),
|
||||
|
||||
// Subagent types
|
||||
"SubagentConfig": reflect.ValueOf((*SubagentConfig)(nil)),
|
||||
"SubagentResult": reflect.ValueOf((*SubagentResult)(nil)),
|
||||
"SubagentUsage": reflect.ValueOf((*SubagentUsage)(nil)),
|
||||
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
|
||||
"SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
|
||||
@@ -40,6 +40,37 @@ func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool
|
||||
return tools
|
||||
}
|
||||
|
||||
// coreToolKinds maps built-in tool names to their kind classification.
|
||||
var coreToolKinds = map[string]string{
|
||||
"bash": "execute",
|
||||
"edit": "edit",
|
||||
"write": "edit",
|
||||
"read": "read",
|
||||
"ls": "read",
|
||||
"grep": "search",
|
||||
"find": "search",
|
||||
"spawn_subagent": "agent",
|
||||
}
|
||||
|
||||
// toolKindFor returns the ToolKind for a given tool name, defaulting to
|
||||
// "execute" for unknown tools (including MCP tools).
|
||||
func toolKindFor(toolName string) string {
|
||||
if kind, ok := coreToolKinds[toolName]; ok {
|
||||
return kind
|
||||
}
|
||||
return "execute"
|
||||
}
|
||||
|
||||
// parseToolArgsJSON attempts to parse JSON-encoded tool args into a map.
|
||||
// Returns nil on failure (non-fatal convenience parsing).
|
||||
func parseToolArgsJSON(input string) map[string]any {
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal([]byte(input), &parsed) == nil {
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrappedTool — intercepts tool calls through the extension runner
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,12 +94,16 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
fmt.Errorf("tool %q disabled by extension", toolName)
|
||||
}
|
||||
|
||||
kind := toolKindFor(toolName)
|
||||
|
||||
// 1. Emit ToolCall — extensions can block execution.
|
||||
if w.runner.HasHandlers(ToolCall) {
|
||||
result, _ := w.runner.Emit(ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
ToolCallID: call.ID,
|
||||
ToolKind: kind,
|
||||
Input: call.Input,
|
||||
ParsedArgs: parseToolArgsJSON(call.Input),
|
||||
Source: "llm",
|
||||
})
|
||||
if r, ok := result.(ToolCallResult); ok && r.Block {
|
||||
@@ -83,7 +118,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
// 2. Emit ToolExecutionStart.
|
||||
if w.runner.HasHandlers(ToolExecutionStart) {
|
||||
_, _ = w.runner.Emit(ToolExecutionStartEvent{ToolName: toolName})
|
||||
_, _ = w.runner.Emit(ToolExecutionStartEvent{ToolCallID: call.ID, ToolName: toolName, ToolKind: kind})
|
||||
}
|
||||
|
||||
// 3. Execute the actual tool.
|
||||
@@ -91,16 +126,19 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
// 4. Emit ToolExecutionEnd.
|
||||
if w.runner.HasHandlers(ToolExecutionEnd) {
|
||||
_, _ = w.runner.Emit(ToolExecutionEndEvent{ToolName: toolName})
|
||||
_, _ = w.runner.Emit(ToolExecutionEndEvent{ToolCallID: call.ID, ToolName: toolName, ToolKind: kind})
|
||||
}
|
||||
|
||||
// 5. Emit ToolResult — extensions can modify output.
|
||||
if w.runner.HasHandlers(ToolResult) {
|
||||
result, _ := w.runner.Emit(ToolResultEvent{
|
||||
ToolName: toolName,
|
||||
Input: call.Input,
|
||||
Content: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: toolName,
|
||||
ToolKind: kind,
|
||||
Input: call.Input,
|
||||
Content: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
Metadata: resp.Metadata,
|
||||
})
|
||||
if r, ok := result.(ToolResultResult); ok {
|
||||
if r.Content != nil {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// ProviderPool manages reusable LLM provider instances to reduce overhead
|
||||
// when spawning multiple subagents or making repeated completion calls.
|
||||
type ProviderPool struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*pooledProvider
|
||||
ttl time.Duration
|
||||
closed bool
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
type pooledProvider struct {
|
||||
model fantasy.LanguageModel
|
||||
closer func() error
|
||||
providerOpts fantasy.ProviderOptions
|
||||
created time.Time
|
||||
lastUsed time.Time
|
||||
refs int32
|
||||
}
|
||||
|
||||
// DefaultPoolTTL is the default time-to-live for idle pooled providers.
|
||||
const DefaultPoolTTL = 5 * time.Minute
|
||||
|
||||
// globalPool is the singleton provider pool instance.
|
||||
var globalPool *ProviderPool
|
||||
var poolOnce sync.Once
|
||||
|
||||
// GetGlobalPool returns the singleton provider pool instance.
|
||||
func GetGlobalPool() *ProviderPool {
|
||||
poolOnce.Do(func() {
|
||||
globalPool = NewProviderPool(DefaultPoolTTL)
|
||||
})
|
||||
return globalPool
|
||||
}
|
||||
|
||||
// NewProviderPool creates a provider pool with the given TTL for idle providers.
|
||||
func NewProviderPool(ttl time.Duration) *ProviderPool {
|
||||
p := &ProviderPool{
|
||||
providers: make(map[string]*pooledProvider),
|
||||
ttl: ttl,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
go p.cleanupLoop()
|
||||
return p
|
||||
}
|
||||
|
||||
// Get returns a provider for the model string, creating one if needed.
|
||||
// The returned release function must be called when the provider is no longer
|
||||
// needed. The provider may be reused by subsequent Get calls.
|
||||
func (p *ProviderPool) Get(ctx context.Context, modelString string) (fantasy.LanguageModel, fantasy.ProviderOptions, func(), error) {
|
||||
p.mu.Lock()
|
||||
|
||||
// Check if we have an existing provider.
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
pp.refs++
|
||||
pp.lastUsed = time.Now()
|
||||
p.mu.Unlock()
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
// Create a new provider outside the lock.
|
||||
config := &ProviderConfig{ModelString: modelString}
|
||||
result, err := CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check: another goroutine may have created one while we were unlocked.
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
// Close the one we just created and use the existing one.
|
||||
if result.Closer != nil {
|
||||
_ = result.Closer.Close()
|
||||
}
|
||||
pp.refs++
|
||||
pp.lastUsed = time.Now()
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
var closerFn func() error
|
||||
if result.Closer != nil {
|
||||
closerFn = result.Closer.Close
|
||||
}
|
||||
|
||||
pp := &pooledProvider{
|
||||
model: result.Model,
|
||||
closer: closerFn,
|
||||
providerOpts: result.ProviderOptions,
|
||||
created: time.Now(),
|
||||
lastUsed: time.Now(),
|
||||
refs: 1,
|
||||
}
|
||||
p.providers[modelString] = pp
|
||||
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
func (p *ProviderPool) release(modelString string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
pp.refs--
|
||||
pp.lastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProviderPool) cleanupLoop() {
|
||||
ticker := time.NewTicker(p.ttl / 2)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.closeCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProviderPool) cleanup() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, pp := range p.providers {
|
||||
// Only clean up providers with no active references and past TTL.
|
||||
if pp.refs <= 0 && now.Sub(pp.lastUsed) > p.ttl {
|
||||
if pp.closer != nil {
|
||||
_ = pp.closer()
|
||||
}
|
||||
delete(p.providers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the pool and releases all providers.
|
||||
func (p *ProviderPool) Close() {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.closed = true
|
||||
close(p.closeCh)
|
||||
|
||||
for key, pp := range p.providers {
|
||||
if pp.closer != nil {
|
||||
_ = pp.closer()
|
||||
}
|
||||
delete(p.providers, key)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stats returns current pool statistics.
|
||||
func (p *ProviderPool) Stats() PoolStats {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
stats := PoolStats{
|
||||
TotalProviders: len(p.providers),
|
||||
}
|
||||
for _, pp := range p.providers {
|
||||
if pp.refs > 0 {
|
||||
stats.ActiveProviders++
|
||||
} else {
|
||||
stats.IdleProviders++
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// PoolStats contains provider pool statistics.
|
||||
type PoolStats struct {
|
||||
TotalProviders int
|
||||
ActiveProviders int
|
||||
IdleProviders int
|
||||
}
|
||||
@@ -210,10 +210,11 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
}
|
||||
}
|
||||
|
||||
// Validate environment variables
|
||||
if err := registry.ValidateEnvironment(provider, config.ProviderAPIKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// NOTE: We intentionally skip registry.ValidateEnvironment() here.
|
||||
// Each create*Provider function handles its own auth resolution and
|
||||
// produces provider-specific error messages. The early env-var check
|
||||
// was too narrow — it didn't account for stored credentials (e.g.
|
||||
// OAuth tokens from 'kit auth login') and blocked valid auth paths.
|
||||
|
||||
// Validate config against known model limits when metadata is available
|
||||
if modelInfo != nil {
|
||||
@@ -320,6 +321,8 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
// createAutoRoutedAnthropicProvider creates an anthropic provider for
|
||||
// third-party providers with anthropic-compatible APIs (e.g. minimax).
|
||||
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
@@ -414,6 +417,16 @@ func validateModelConfig(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// clearConflictingAnthropicSamplingParams ensures that temperature and top_p are
|
||||
// not both sent to the Anthropic API, which rejects requests containing both.
|
||||
// When both are set (typically from defaults), top_p is cleared so that
|
||||
// temperature takes precedence.
|
||||
func clearConflictingAnthropicSamplingParams(config *ProviderConfig) {
|
||||
if config.Temperature != nil && config.TopP != nil {
|
||||
config.TopP = nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildOpenAIProviderOptions returns fantasy.ProviderOptions configured for
|
||||
// OpenAI Responses API models. For reasoning models it sets reasoning_summary
|
||||
// to "auto", includes encrypted reasoning content, and maps the ThinkingLevel
|
||||
@@ -498,6 +511,8 @@ func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fan
|
||||
}
|
||||
|
||||
func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey, source, err := auth.GetAnthropicAPIKey(config.ProviderAPIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -540,6 +555,8 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
|
||||
}
|
||||
|
||||
func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
projectID := firstNonEmpty(
|
||||
os.Getenv("GOOGLE_VERTEX_PROJECT"),
|
||||
os.Getenv("ANTHROPIC_VERTEX_PROJECT_ID"),
|
||||
@@ -1026,9 +1043,21 @@ type oauthTransport struct {
|
||||
}
|
||||
|
||||
func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Resolve the freshest available token. The credential manager
|
||||
// automatically refreshes tokens nearing expiry (5-minute buffer).
|
||||
// This keeps long-lived sessions (e.g. ACP) working across token
|
||||
// renewals. Falls back to the originally-provided token if the
|
||||
// credential manager is unavailable.
|
||||
token := t.accessToken
|
||||
if cm, err := auth.NewCredentialManager(); err == nil {
|
||||
if fresh, err := cm.GetValidAccessToken(); err == nil && fresh != "" {
|
||||
token = fresh
|
||||
}
|
||||
}
|
||||
|
||||
newReq := req.Clone(req.Context())
|
||||
newReq.Header.Del("x-api-key")
|
||||
newReq.Header.Set("Authorization", "Bearer "+t.accessToken)
|
||||
newReq.Header.Set("Authorization", "Bearer "+token)
|
||||
newReq.Header.Set("anthropic-beta", "oauth-2025-04-20")
|
||||
newReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ func TestCreateOAuthHTTPClient(t *testing.T) {
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
return
|
||||
}
|
||||
|
||||
// Check that the transport is an oauthTransport
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
)
|
||||
|
||||
//go:embed embedded_models.json
|
||||
@@ -171,14 +173,27 @@ func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
|
||||
return providerInfo.Env, nil
|
||||
}
|
||||
|
||||
// ValidateEnvironment checks if required environment variables are set.
|
||||
// Returns nil for providers not in the registry (unknown providers are
|
||||
// assumed to handle auth themselves or via --provider-api-key).
|
||||
// ValidateEnvironment checks if required credentials are available for a
|
||||
// provider. It checks the explicit API key, stored credentials (for
|
||||
// providers that support them, such as Anthropic OAuth), and environment
|
||||
// variables. Returns nil for providers not in the registry (unknown
|
||||
// providers are assumed to handle auth themselves or via --provider-api-key).
|
||||
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
|
||||
if apiKey != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For anthropic, also check stored credentials (OAuth / API key)
|
||||
// since auth resolution goes through the credential manager, not
|
||||
// just environment variables.
|
||||
if provider == "anthropic" {
|
||||
if cm, err := auth.NewCredentialManager(); err == nil {
|
||||
if has, _ := cm.HasAnthropicCredentials(); has {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envVars, err := r.GetRequiredEnvVars(provider)
|
||||
if err != nil {
|
||||
// Unknown provider — nothing to validate
|
||||
|
||||
@@ -38,6 +38,10 @@ type SessionHeader struct {
|
||||
Timestamp time.Time `json:"timestamp"` // creation time
|
||||
Cwd string `json:"cwd"` // working directory
|
||||
ParentSession string `json:"parent_session,omitempty"` // path to parent if forked
|
||||
|
||||
// Subagent fields (set when session is created by a subagent)
|
||||
ParentSessionID string `json:"parent_session_id,omitempty"` // UUID of parent session
|
||||
SubagentTask string `json:"subagent_task,omitempty"` // original task prompt
|
||||
}
|
||||
|
||||
// Entry is the common structure shared by all tree entries (everything except
|
||||
|
||||
@@ -29,6 +29,12 @@ type SessionInfo struct {
|
||||
// ParentSessionPath is the parent session path if this session was forked.
|
||||
ParentSessionPath string
|
||||
|
||||
// ParentSessionID is the UUID of the parent session (for subagent sessions).
|
||||
ParentSessionID string
|
||||
|
||||
// SubagentTask is the original task prompt (for subagent sessions).
|
||||
SubagentTask string
|
||||
|
||||
// Created is when the session was first created.
|
||||
Created time.Time
|
||||
|
||||
@@ -162,6 +168,8 @@ func extractSessionInfo(path string) (*SessionInfo, error) {
|
||||
info.Created = h.Timestamp
|
||||
info.Modified = h.Timestamp
|
||||
info.ParentSessionPath = h.ParentSession
|
||||
info.ParentSessionID = h.ParentSessionID
|
||||
info.SubagentTask = h.SubagentTask
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -245,3 +253,27 @@ func extractTextPreview(partsJSON json.RawMessage) string {
|
||||
func DeleteSession(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// ListChildSessions returns all sessions that have the given session ID as
|
||||
// their parent. This is useful for finding subagent sessions spawned from
|
||||
// a parent session. Results are sorted by creation time (newest first).
|
||||
func ListChildSessions(parentID string) ([]SessionInfo, error) {
|
||||
if parentID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
allSessions, err := ListAllSessions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var children []SessionInfo
|
||||
for _, s := range allSessions {
|
||||
if s.ParentSessionID == parentID {
|
||||
children = append(children, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Already sorted by modification time from ListAllSessions
|
||||
return children, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type blockRenderer struct {
|
||||
align *lipgloss.Position
|
||||
borderColor *color.Color
|
||||
background *color.Color
|
||||
foreground *color.Color
|
||||
fullWidth bool
|
||||
noBorder bool
|
||||
paddingTop int
|
||||
@@ -123,6 +124,15 @@ func WithBackground(c color.Color) renderingOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithForeground returns a renderingOption that overrides the default text
|
||||
// foreground color (theme.Text) for the block. Useful for muted or
|
||||
// de-emphasized content blocks.
|
||||
func WithForeground(c color.Color) renderingOption {
|
||||
return func(br *blockRenderer) {
|
||||
br.foreground = &c
|
||||
}
|
||||
}
|
||||
|
||||
// WithWidth returns a renderingOption that sets a specific width for the block
|
||||
// in characters. This overrides the default container width and allows precise
|
||||
// control over the block's horizontal dimensions.
|
||||
@@ -167,13 +177,19 @@ func renderContentBlock(content string, containerWidth int, options ...rendering
|
||||
|
||||
theme := GetTheme()
|
||||
|
||||
// Resolve foreground color: caller override or theme default.
|
||||
fgColor := theme.Text
|
||||
if renderer.foreground != nil {
|
||||
fgColor = *renderer.foreground
|
||||
}
|
||||
|
||||
// Single-pass render: padding, border, and foreground in one style.
|
||||
style := lipgloss.NewStyle().
|
||||
PaddingLeft(renderer.paddingLeft).
|
||||
PaddingRight(renderer.paddingRight).
|
||||
PaddingTop(renderer.paddingTop).
|
||||
PaddingBottom(renderer.paddingBottom).
|
||||
Foreground(theme.Text)
|
||||
Foreground(fgColor)
|
||||
|
||||
if hasBorder {
|
||||
style = style.BorderStyle(lipgloss.ThickBorder())
|
||||
|
||||
@@ -348,6 +348,9 @@ func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
|
||||
// Receive first chunk — spinner should keep running.
|
||||
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
|
||||
|
||||
// Flush pending chunks (simulates the 16ms tick firing).
|
||||
c = sendStreamMsg(c, streamFlushTickMsg{})
|
||||
|
||||
if !c.spinning {
|
||||
t.Fatal("expected spinning=true after first chunk")
|
||||
}
|
||||
@@ -372,6 +375,9 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
|
||||
c = sendStreamMsg(c, app.StreamChunkEvent{Content: chunk})
|
||||
}
|
||||
|
||||
// Flush pending chunks (simulates the 16ms tick firing).
|
||||
c = sendStreamMsg(c, streamFlushTickMsg{})
|
||||
|
||||
got := c.streamContent.String()
|
||||
want := "Hello, world!"
|
||||
if got != want {
|
||||
@@ -397,8 +403,8 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
|
||||
if !c.spinning {
|
||||
t.Fatal("expected spinning=true during tool execution")
|
||||
}
|
||||
if !strings.Contains(c.spinnerMsg, "exec_tool") {
|
||||
t.Fatalf("expected spinnerMsg to contain tool name, got %q", c.spinnerMsg)
|
||||
if len(c.activeTools) != 1 || !strings.Contains(c.activeTools[0], "exec_tool") {
|
||||
t.Fatalf("expected activeTools to contain tool name, got %v", c.activeTools)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected tick cmd from ToolExecutionEvent{IsStarting:true}")
|
||||
@@ -410,7 +416,11 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
|
||||
c := newTestStream()
|
||||
// Start spinning first (simulating execution in progress).
|
||||
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
|
||||
c.spinnerMsg = "Executing some_tool…"
|
||||
// Simulate a tool starting
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{
|
||||
ToolName: "some_tool",
|
||||
IsStarting: true,
|
||||
})
|
||||
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{
|
||||
ToolName: "some_tool",
|
||||
@@ -420,8 +430,41 @@ func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
|
||||
if !c.spinning {
|
||||
t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)")
|
||||
}
|
||||
if c.spinnerMsg != "" {
|
||||
t.Fatalf("expected spinnerMsg cleared after tool finished, got %q", c.spinnerMsg)
|
||||
if len(c.activeTools) != 0 {
|
||||
t.Fatalf("expected activeTools cleared after tool finished, got %v", c.activeTools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamComponent_ParallelToolExecution verifies multiple tools can run concurrently.
|
||||
func TestStreamComponent_ParallelToolExecution(t *testing.T) {
|
||||
c := newTestStream()
|
||||
|
||||
// Start three tools in parallel
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: true})
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: true})
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: true})
|
||||
|
||||
if len(c.activeTools) != 3 {
|
||||
t.Fatalf("expected 3 active tools, got %d: %v", len(c.activeTools), c.activeTools)
|
||||
}
|
||||
|
||||
// Check SpinnerView shows all tools
|
||||
view := c.SpinnerView()
|
||||
if !strings.Contains(view, "Running:") {
|
||||
t.Fatalf("expected spinner view to contain 'Running:' for multiple tools, got %q", view)
|
||||
}
|
||||
|
||||
// Finish one tool
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "grep", IsStarting: false})
|
||||
if len(c.activeTools) != 2 {
|
||||
t.Fatalf("expected 2 active tools after one finished, got %d: %v", len(c.activeTools), c.activeTools)
|
||||
}
|
||||
|
||||
// Finish remaining tools
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "read", IsStarting: false})
|
||||
c = sendStreamMsg(c, app.ToolExecutionEvent{ToolName: "find", IsStarting: false})
|
||||
if len(c.activeTools) != 0 {
|
||||
t.Fatalf("expected 0 active tools after all finished, got %d: %v", len(c.activeTools), c.activeTools)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,8 +523,8 @@ func TestStreamComponent_Reset(t *testing.T) {
|
||||
if !c.timestamp.IsZero() {
|
||||
t.Fatal("expected zero timestamp after Reset()")
|
||||
}
|
||||
if c.spinnerMsg != "" {
|
||||
t.Fatalf("expected spinnerMsg empty after Reset(), got %q", c.spinnerMsg)
|
||||
if len(c.activeTools) != 0 {
|
||||
t.Fatalf("expected activeTools empty after Reset(), got %v", c.activeTools)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,15 +44,20 @@ func (r *CompactRenderer) SetWidth(width int) {
|
||||
// and metadata.
|
||||
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
|
||||
theme := getTheme()
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.Secondary).Render(">")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true).Render("User")
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.Info).Render(">")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render("User")
|
||||
|
||||
// Convert single newlines to paragraph breaks so they survive glamour's
|
||||
// markdown rendering (glamour treats single \n as a soft break).
|
||||
content = strings.ReplaceAll(content, "\n", "\n\n")
|
||||
|
||||
// Format content for user messages (preserve formatting, no truncation)
|
||||
compactContent := r.formatUserAssistantContent(content)
|
||||
// Only run markdown rendering when the message contains code spans or
|
||||
// fenced code blocks. Plain text is rendered directly so that newlines
|
||||
// are preserved without the extra paragraph spacing glamour adds.
|
||||
var compactContent string
|
||||
if strings.Contains(content, "`") {
|
||||
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
|
||||
compactContent = r.formatUserAssistantContent(mdContent)
|
||||
compactContent = removeBlankLines(compactContent)
|
||||
} else {
|
||||
compactContent = content
|
||||
}
|
||||
|
||||
// Handle multi-line content
|
||||
lines := strings.Split(compactContent, "\n")
|
||||
@@ -170,7 +175,7 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
if extRd != nil && extRd.DisplayName != "" {
|
||||
displayName = extRd.DisplayName
|
||||
}
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
|
||||
|
||||
// Format params — check extension renderer first.
|
||||
paramBudget := max(r.width-10-len(displayName), 20)
|
||||
@@ -235,8 +240,8 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
// formatted to fit on a single line for minimal space usage.
|
||||
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
|
||||
theme := getTheme()
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.System).Render("*")
|
||||
label := lipgloss.NewStyle().Foreground(theme.System).Bold(true).Render("System")
|
||||
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("◇")
|
||||
label := lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render("System")
|
||||
|
||||
compactContent := r.formatCompactContent(content)
|
||||
|
||||
|
||||
@@ -89,10 +89,10 @@ func NewInputComponent(width int, title string, appCtrl AppController) *InputCom
|
||||
ta.SetHeight(3) // Default to 3 lines like huh
|
||||
ta.Focus()
|
||||
|
||||
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
|
||||
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
|
||||
// Enter always submits the input.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "alt+enter"),
|
||||
key.WithKeys("ctrl+j", "shift+enter"),
|
||||
key.WithHelp("ctrl+j", "insert newline"),
|
||||
)
|
||||
|
||||
@@ -419,7 +419,7 @@ func (s *InputComponent) View() tea.View {
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
hint := "enter submit • ctrl+j / alt+enter new line • ctrl+v paste image"
|
||||
hint := "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(hint))
|
||||
}
|
||||
|
||||
+55
-115
@@ -3,8 +3,7 @@ package ui
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,6 +11,9 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// ansiEscapeRe matches ANSI escape sequences used for terminal styling.
|
||||
var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
// MessageType represents different categories of messages displayed in the UI,
|
||||
// each with distinct visual styling and formatting rules.
|
||||
type MessageType int
|
||||
@@ -154,21 +156,6 @@ type MessageRenderer struct {
|
||||
getToolRenderer func(toolName string) *ToolRendererData
|
||||
}
|
||||
|
||||
// getSystemUsername returns the current system username, fallback to "User"
|
||||
func getSystemUsername() string {
|
||||
if currentUser, err := user.Current(); err == nil && currentUser.Username != "" {
|
||||
return currentUser.Username
|
||||
}
|
||||
// Fallback to environment variable
|
||||
if username := os.Getenv("USER"); username != "" {
|
||||
return username
|
||||
}
|
||||
if username := os.Getenv("USERNAME"); username != "" {
|
||||
return username
|
||||
}
|
||||
return "User"
|
||||
}
|
||||
|
||||
// NewMessageRenderer creates and initializes a new MessageRenderer with the specified
|
||||
// terminal width and debug mode setting. The width parameter determines line wrapping
|
||||
// and layout calculations.
|
||||
@@ -189,31 +176,30 @@ func (r *MessageRenderer) SetWidth(width int) {
|
||||
// formatting, including the system username, timestamp, and markdown-rendered content.
|
||||
// The message is displayed with a colored right border for visual distinction.
|
||||
func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
|
||||
// Format timestamp and username
|
||||
timeStr := timestamp.Local().Format("15:04")
|
||||
username := getSystemUsername()
|
||||
|
||||
// Convert single newlines to paragraph breaks so they survive glamour's
|
||||
// markdown rendering (glamour treats single \n as a soft break).
|
||||
content = strings.ReplaceAll(content, "\n", "\n\n")
|
||||
|
||||
theme := getTheme()
|
||||
|
||||
messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders
|
||||
// Only run markdown rendering when the message contains code spans or
|
||||
// fenced code blocks. Plain text is rendered directly so that newlines
|
||||
// are preserved without the extra paragraph spacing glamour adds.
|
||||
var messageContent string
|
||||
if strings.Contains(content, "`") {
|
||||
// Glamour treats single \n as a soft break, so convert to paragraph
|
||||
// breaks and collapse the resulting blank lines after rendering.
|
||||
mdContent := strings.ReplaceAll(content, "\n", "\n\n")
|
||||
messageContent = r.renderMarkdown(mdContent, r.width-8)
|
||||
messageContent = removeBlankLines(messageContent)
|
||||
} else {
|
||||
messageContent = content
|
||||
}
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" %s (%s)", username, timeStr)
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n")
|
||||
|
||||
// Combine content and info
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the block renderer — left border with Primary color, no background.
|
||||
// Left border with Blue color for user messages.
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.Primary),
|
||||
WithBorderColor(theme.Info),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
@@ -230,14 +216,8 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time)
|
||||
// are displayed with a special "Finished without output" message. The message features
|
||||
// a colored left border for visual distinction.
|
||||
func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
|
||||
// Format timestamp and model info with better defaults
|
||||
timeStr := timestamp.Local().Format("15:04")
|
||||
if modelName == "" {
|
||||
modelName = "Assistant"
|
||||
}
|
||||
|
||||
// Handle empty content with better styling
|
||||
theme := getTheme()
|
||||
|
||||
var messageContent string
|
||||
if strings.TrimSpace(content) == "" {
|
||||
messageContent = lipgloss.NewStyle().
|
||||
@@ -246,21 +226,16 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
|
||||
Align(lipgloss.Center).
|
||||
Render("Finished without output")
|
||||
} else {
|
||||
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
|
||||
messageContent = r.renderMarkdown(content, r.width-8)
|
||||
}
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" %s (%s)", modelName, timeStr)
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n")
|
||||
|
||||
// Combine content and info
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the new block renderer — no borders for agent messages.
|
||||
// Left border with Primary (Mauve) color for assistant messages.
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithNoBorder(),
|
||||
WithBorderColor(theme.Primary),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
@@ -276,35 +251,24 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.
|
||||
// and informational notifications. These messages are displayed with a distinctive system
|
||||
// color border and "KIT System" label to differentiate them from user and AI content.
|
||||
func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
|
||||
// Format timestamp
|
||||
timeStr := timestamp.Local().Format("15:04")
|
||||
|
||||
// Handle empty content with better styling
|
||||
theme := getTheme()
|
||||
|
||||
var messageContent string
|
||||
if strings.TrimSpace(content) == "" {
|
||||
messageContent = lipgloss.NewStyle().
|
||||
Italic(true).
|
||||
Foreground(theme.Muted).
|
||||
Align(lipgloss.Center).
|
||||
Render("No content available")
|
||||
messageContent = "No content available"
|
||||
} else if strings.Contains(content, "`") {
|
||||
messageContent = r.renderMarkdown(content, r.width-8)
|
||||
} else {
|
||||
messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders
|
||||
messageContent = content
|
||||
}
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" KIT System (%s)", timeStr)
|
||||
fullContent := "◇ " + strings.TrimSuffix(messageContent, "\n")
|
||||
|
||||
// Combine content and info
|
||||
fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the new block renderer
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.System),
|
||||
WithNoBorder(),
|
||||
WithForeground(theme.Muted),
|
||||
WithMarginBottom(1),
|
||||
)
|
||||
|
||||
@@ -322,29 +286,22 @@ func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Tim
|
||||
func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
|
||||
baseStyle := lipgloss.NewStyle()
|
||||
|
||||
// Create the main message style with border using tool color
|
||||
theme := getTheme()
|
||||
style := baseStyle.
|
||||
Width(r.width - 3). // Account for left margin
|
||||
Width(r.width - 3).
|
||||
BorderLeft(true).
|
||||
Foreground(theme.Muted).
|
||||
BorderForeground(theme.Tool).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
PaddingLeft(1).
|
||||
MarginLeft(2). // Add left margin like other messages
|
||||
MarginBottom(1) // Add bottom margin
|
||||
MarginLeft(2).
|
||||
MarginBottom(1)
|
||||
|
||||
// Format timestamp
|
||||
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
|
||||
|
||||
// Create header with debug icon
|
||||
header := baseStyle.
|
||||
Foreground(theme.Tool).
|
||||
Bold(true).
|
||||
Render("🔍 Debug Output")
|
||||
|
||||
// Process and format the message content
|
||||
// Split into lines and format each one
|
||||
lines := strings.Split(message, "\n")
|
||||
var formattedLines []string
|
||||
for _, line := range lines {
|
||||
@@ -357,17 +314,9 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
|
||||
Foreground(theme.Muted).
|
||||
Render(strings.Join(formattedLines, "\n"))
|
||||
|
||||
// Create info line
|
||||
info := baseStyle.
|
||||
Width(r.width - 5). // Account for margins and padding
|
||||
Foreground(theme.Muted).
|
||||
Render(fmt.Sprintf(" KIT (%s)", timeStr))
|
||||
|
||||
// Combine all parts
|
||||
fullContent := lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
content,
|
||||
info,
|
||||
)
|
||||
|
||||
return UIMessage{
|
||||
@@ -382,7 +331,6 @@ func (r *MessageRenderer) RenderDebugMessage(message string, timestamp time.Time
|
||||
func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
|
||||
baseStyle := lipgloss.NewStyle()
|
||||
|
||||
// Create the main message style with border using tool color
|
||||
theme := getTheme()
|
||||
style := baseStyle.
|
||||
Width(r.width - 1).
|
||||
@@ -392,16 +340,11 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
PaddingLeft(1)
|
||||
|
||||
// Format timestamp
|
||||
timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM")
|
||||
|
||||
// Create header with debug icon
|
||||
header := baseStyle.
|
||||
Foreground(theme.Tool).
|
||||
Bold(true).
|
||||
Render("🔧 Debug Configuration")
|
||||
|
||||
// Format configuration settings
|
||||
var configLines []string
|
||||
for key, value := range config {
|
||||
if value != nil {
|
||||
@@ -413,18 +356,10 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
|
||||
Foreground(theme.Muted).
|
||||
Render(strings.Join(configLines, "\n"))
|
||||
|
||||
// Create info line
|
||||
info := baseStyle.
|
||||
Width(r.width - 1).
|
||||
Foreground(theme.Muted).
|
||||
Render(fmt.Sprintf(" KIT (%s)", timeStr))
|
||||
|
||||
// Combine parts
|
||||
parts := []string{header}
|
||||
if len(configLines) > 0 {
|
||||
parts = append(parts, configContent)
|
||||
}
|
||||
parts = append(parts, info)
|
||||
|
||||
rendered := style.Render(
|
||||
lipgloss.JoinVertical(lipgloss.Left, parts...),
|
||||
@@ -442,26 +377,15 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest
|
||||
// bold text to ensure visibility. Error messages include timestamp information and
|
||||
// are displayed with an error-colored border for immediate recognition.
|
||||
func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
|
||||
// Format timestamp
|
||||
timeStr := timestamp.Local().Format("15:04")
|
||||
|
||||
// Format error content
|
||||
theme := getTheme()
|
||||
|
||||
errorContent := lipgloss.NewStyle().
|
||||
Foreground(theme.Error).
|
||||
Bold(true).
|
||||
Render(errorMsg)
|
||||
|
||||
// Create info line
|
||||
info := fmt.Sprintf(" Error (%s)", timeStr)
|
||||
|
||||
// Combine content and info
|
||||
fullContent := errorContent + "\n" +
|
||||
lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info)
|
||||
|
||||
// Use the new block renderer
|
||||
rendered := renderContentBlock(
|
||||
fullContent,
|
||||
errorContent,
|
||||
r.width,
|
||||
WithAlign(lipgloss.Left),
|
||||
WithBorderColor(theme.Error),
|
||||
@@ -559,7 +483,7 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin
|
||||
if extRd != nil && extRd.DisplayName != "" {
|
||||
displayName = extRd.DisplayName
|
||||
}
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(displayName)
|
||||
nameStr := lipgloss.NewStyle().Foreground(theme.Info).Bold(true).Render(displayName)
|
||||
|
||||
// Format params with width budget for the header line.
|
||||
// Check extension renderer for custom header params first.
|
||||
@@ -710,3 +634,19 @@ func (r *MessageRenderer) renderMarkdown(content string, width int) string {
|
||||
rendered := toMarkdown(content, width)
|
||||
return strings.TrimSuffix(rendered, "\n")
|
||||
}
|
||||
|
||||
// removeBlankLines removes lines that are visually blank from rendered output.
|
||||
// Glamour wraps every character (including padding spaces) with ANSI color
|
||||
// codes, so we must strip escape sequences before checking whether a line is
|
||||
// empty. This collapses paragraph spacing so user messages render without
|
||||
// extra vertical gaps.
|
||||
func removeBlankLines(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
filtered := lines[:0]
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(ansiEscapeRe.ReplaceAllString(line, "")) != "" {
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(filtered, "\n")
|
||||
}
|
||||
|
||||
+54
-12
@@ -396,6 +396,12 @@ type AppModel struct {
|
||||
// the input and move to scrollback when the agent picks them up.
|
||||
queuedMessages []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
|
||||
// flushed first, preserving chronological order.
|
||||
pendingUserPrints []string
|
||||
|
||||
// canceling tracks whether the user has pressed ESC once during stateWorking.
|
||||
// A second ESC within 2 seconds will cancel the current step.
|
||||
canceling bool
|
||||
@@ -1091,12 +1097,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if qLen > 0 {
|
||||
// Queued: anchor the message text above the input with a
|
||||
// "queued" badge. It will be printed to scrollback when
|
||||
// the agent picks it up (on QueueUpdatedEvent).
|
||||
// the agent picks it up (via SpinnerEvent).
|
||||
m.queuedMessages = append(m.queuedMessages, displayText)
|
||||
m.distributeHeight()
|
||||
} else {
|
||||
// Started immediately: print to scrollback now.
|
||||
cmds = append(cmds, m.printUserMessage(displayText))
|
||||
// Started immediately. Flush any leftover stream content
|
||||
// from the previous step first, then print the user
|
||||
// message — combined in a single tea.Println so
|
||||
// scrollback stays in chronological order.
|
||||
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
|
||||
cmds = append(cmds, m.flushStreamAndPendingUserMessages())
|
||||
}
|
||||
} else {
|
||||
cmds = append(cmds, m.printUserMessage(displayText))
|
||||
@@ -1119,10 +1129,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// SpinnerEvent{Show: true} means a new agent step has started (either
|
||||
// freshly or from the queue after a previous step completed). Flush
|
||||
// any leftover stream content from the previous step to scrollback
|
||||
// before starting the new one. This deferred flush avoids shrinking
|
||||
// the view at step-completion time (which leaves blank lines).
|
||||
// before starting the new one, followed by any pending user messages
|
||||
// from the queue. Everything is emitted in a single tea.Println to
|
||||
// guarantee chronological ordering in scrollback.
|
||||
if msg.Show {
|
||||
cmds = append(cmds, m.flushStreamContent())
|
||||
cmds = append(cmds, m.flushStreamAndPendingUserMessages())
|
||||
m.state = stateWorking
|
||||
m.distributeHeight()
|
||||
}
|
||||
@@ -1189,13 +1200,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Informational — no action needed by parent.
|
||||
|
||||
case app.QueueUpdatedEvent:
|
||||
// drainQueue popped item(s) from the queue. Move consumed messages
|
||||
// from the anchored display to scrollback (they are now being processed
|
||||
// or about to be).
|
||||
// drainQueue popped item(s) from the queue. Move consumed
|
||||
// messages to pendingUserPrints — they will be printed to
|
||||
// scrollback in the next SpinnerEvent{Show: true} after the
|
||||
// previous assistant response is flushed.
|
||||
for len(m.queuedMessages) > msg.Length {
|
||||
text := m.queuedMessages[0]
|
||||
m.queuedMessages = m.queuedMessages[1:]
|
||||
cmds = append(cmds, m.printUserMessage(text))
|
||||
m.pendingUserPrints = append(m.pendingUserPrints, text)
|
||||
}
|
||||
m.distributeHeight()
|
||||
|
||||
@@ -2080,8 +2092,7 @@ func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) tea.Cmd {
|
||||
// and on step completion.
|
||||
//
|
||||
// After flushing, a ClearScreen is issued to force a full terminal redraw.
|
||||
// When
|
||||
// the stream content is moved to scrollback the view height shrinks, and
|
||||
// When the stream content is moved to scrollback the view height shrinks, and
|
||||
// bubbletea's inline renderer doesn't clear the orphaned terminal rows
|
||||
// below the managed region. ClearScreen ensures a clean redraw.
|
||||
func (m *AppModel) flushStreamContent() tea.Cmd {
|
||||
@@ -2099,6 +2110,37 @@ func (m *AppModel) flushStreamContent() tea.Cmd {
|
||||
)
|
||||
}
|
||||
|
||||
// flushStreamAndPendingUserMessages flushes the previous assistant response
|
||||
// and any pending queued user messages to scrollback in a single tea.Println
|
||||
// call, guaranteeing chronological order. Called from SpinnerEvent{Show: true}
|
||||
// where all previous stream chunks are guaranteed to have been processed.
|
||||
func (m *AppModel) flushStreamAndPendingUserMessages() tea.Cmd {
|
||||
var parts []string
|
||||
|
||||
// 1. Flush previous stream content (assistant response).
|
||||
if m.stream != nil {
|
||||
if content := m.stream.GetRenderedContent(); content != "" {
|
||||
m.stream.Reset()
|
||||
parts = append(parts, content)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Render pending user messages from the queue.
|
||||
for _, text := range m.pendingUserPrints {
|
||||
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
|
||||
parts = append(parts, rendered)
|
||||
}
|
||||
m.pendingUserPrints = nil
|
||||
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tea.Sequence(
|
||||
tea.Println(strings.Join(parts, "\n")),
|
||||
func() tea.Msg { return tea.ClearScreen() },
|
||||
)
|
||||
}
|
||||
|
||||
// distributeHeight recalculates child component heights after a window resize,
|
||||
// queue change, widget update, or state transition, and propagates the computed
|
||||
// stream height to the StreamComponent.
|
||||
|
||||
@@ -405,14 +405,16 @@ func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestQueuedMessages_poppedOnQueueUpdated verifies that QueueUpdatedEvent pops
|
||||
// consumed messages from queuedMessages and prints them to scrollback.
|
||||
// consumed messages from queuedMessages and moves them to pendingUserPrints.
|
||||
// The actual printing is deferred to SpinnerEvent{Show: true} to preserve
|
||||
// chronological order with the preceding assistant response.
|
||||
func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
m.queuedMessages = []string{"first", "second", "third"}
|
||||
|
||||
// Simulate drainQueue popping one item (length goes from 3 to 2).
|
||||
_, cmd := m.Update(app.QueueUpdatedEvent{Length: 2})
|
||||
m = sendMsg(m, app.QueueUpdatedEvent{Length: 2})
|
||||
|
||||
if len(m.queuedMessages) != 2 {
|
||||
t.Fatalf("expected 2 queued messages after pop, got %d", len(m.queuedMessages))
|
||||
@@ -420,14 +422,17 @@ func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
|
||||
if m.queuedMessages[0] != "second" {
|
||||
t.Fatalf("expected first remaining message 'second', got %q", m.queuedMessages[0])
|
||||
}
|
||||
// Should produce a cmd (tea.Println for the popped user message).
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd (tea.Println) for popped message")
|
||||
// Popped message should be deferred to pendingUserPrints.
|
||||
if len(m.pendingUserPrints) != 1 {
|
||||
t.Fatalf("expected 1 pending user print, got %d", len(m.pendingUserPrints))
|
||||
}
|
||||
if m.pendingUserPrints[0] != "first" {
|
||||
t.Fatalf("expected pending message 'first', got %q", m.pendingUserPrints[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueuedMessages_allPoppedOnDrain verifies that QueueUpdatedEvent with
|
||||
// Length=0 pops all remaining queued messages.
|
||||
// Length=0 pops all remaining queued messages into pendingUserPrints.
|
||||
func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
|
||||
ctrl := &stubAppController{}
|
||||
m, _, _ := newTestAppModel(ctrl)
|
||||
@@ -438,6 +443,9 @@ func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
|
||||
if len(m.queuedMessages) != 0 {
|
||||
t.Fatalf("expected 0 queued messages after drain, got %d", len(m.queuedMessages))
|
||||
}
|
||||
if len(m.pendingUserPrints) != 2 {
|
||||
t.Fatalf("expected 2 pending user prints, got %d", len(m.pendingUserPrints))
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -83,7 +83,7 @@ func newInputPrompt(message, placeholder, defaultValue string, width, height int
|
||||
|
||||
// Prevent Enter from inserting a newline — we intercept it for submit.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "alt+enter"),
|
||||
key.WithKeys("ctrl+j", "shift+enter"),
|
||||
)
|
||||
|
||||
if defaultValue != "" {
|
||||
|
||||
@@ -42,10 +42,10 @@ func NewSlashCommandInput(width int, title string) *SlashCommandInput {
|
||||
ta.SetHeight(3) // Default to 3 lines like huh
|
||||
ta.Focus()
|
||||
|
||||
// Override InsertNewline so only ctrl+j and alt+enter insert newlines.
|
||||
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
|
||||
// Enter always submits the input.
|
||||
ta.KeyMap.InsertNewline = key.NewBinding(
|
||||
key.WithKeys("ctrl+j", "alt+enter"),
|
||||
key.WithKeys("ctrl+j", "shift+enter"),
|
||||
key.WithHelp("ctrl+j", "insert newline"),
|
||||
)
|
||||
|
||||
@@ -227,7 +227,7 @@ func (s *SlashCommandInput) View() tea.View {
|
||||
MarginTop(1).
|
||||
PaddingLeft(3)
|
||||
|
||||
helpText := "enter submit • ctrl+j / alt+enter new line"
|
||||
helpText := "enter submit • ctrl+j / shift+enter new line"
|
||||
|
||||
view.WriteString("\n")
|
||||
view.WriteString(helpStyle.Render(helpText))
|
||||
|
||||
+221
-38
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -69,6 +70,25 @@ func streamSpinnerTickCmd() tea.Cmd {
|
||||
})
|
||||
}
|
||||
|
||||
// streamFlushTickMsg fires when it's time to commit pending chunks to the
|
||||
// main content builders and trigger a re-render. This coalesces rapid
|
||||
// streaming chunks into fewer expensive markdown re-renders.
|
||||
type streamFlushTickMsg struct{}
|
||||
|
||||
// streamFlushInterval is the coalescing window for stream chunks. Chunks
|
||||
// arriving within this window are batched into a single render pass.
|
||||
// 16ms ≈ 60 fps — fast enough to appear smooth, slow enough to coalesce
|
||||
// bursts from the LLM provider.
|
||||
const streamFlushInterval = 16 * time.Millisecond
|
||||
|
||||
// streamFlushTickCmd returns a tea.Cmd that fires streamFlushTickMsg after
|
||||
// the coalescing interval.
|
||||
func streamFlushTickCmd() tea.Cmd {
|
||||
return tea.Tick(streamFlushInterval, func(_ time.Time) tea.Msg {
|
||||
return streamFlushTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
// streamPhase tracks what the StreamComponent is currently displaying.
|
||||
type streamPhase int
|
||||
|
||||
@@ -114,19 +134,47 @@ type StreamComponent struct {
|
||||
// spinnerFrame is the current frame index.
|
||||
spinnerFrame int
|
||||
|
||||
// spinnerMsg is the label shown next to the KITT animation (e.g.
|
||||
// "Executing tool_name…"). Empty string means no label.
|
||||
spinnerMsg string
|
||||
// activeTools tracks the names of tools currently executing in parallel.
|
||||
// When multiple tools run concurrently, all are displayed in the spinner.
|
||||
activeTools []string
|
||||
|
||||
// streamContent accumulates all streaming text chunks.
|
||||
// streamContent holds committed streaming text (flushed from pending).
|
||||
streamContent strings.Builder
|
||||
|
||||
// reasoningContent accumulates reasoning/thinking text chunks.
|
||||
// reasoningContent holds committed reasoning text (flushed from pending).
|
||||
reasoningContent strings.Builder
|
||||
|
||||
// thinkingVisible controls whether reasoning blocks are shown or collapsed.
|
||||
// pendingStream accumulates streaming text chunks between flush ticks.
|
||||
// Chunks are written here immediately on arrival, then moved to
|
||||
// streamContent when the flush tick fires.
|
||||
pendingStream strings.Builder
|
||||
|
||||
// pendingReasoning accumulates reasoning chunks between flush ticks.
|
||||
pendingReasoning strings.Builder
|
||||
|
||||
// flushPending is true while a flush tick is in-flight. Prevents
|
||||
// scheduling duplicate ticks when multiple chunks arrive within
|
||||
// the same coalescing window.
|
||||
flushPending bool
|
||||
|
||||
// renderCache holds the last rendered output string. Reused by View()
|
||||
// between flush ticks to avoid redundant markdown re-parsing.
|
||||
renderCache string
|
||||
|
||||
// renderDirty is true when committed content has changed since the
|
||||
// last render. Set on flush tick; cleared after render() rebuilds
|
||||
// the cache.
|
||||
renderDirty bool
|
||||
|
||||
// thinkingVisible controls whether reasoning blocks are expanded or collapsed.
|
||||
thinkingVisible bool
|
||||
|
||||
// reasoningStartTime records when the first reasoning chunk was received.
|
||||
reasoningStartTime time.Time
|
||||
|
||||
// reasoningDuration holds the total reasoning time, frozen when streaming text begins.
|
||||
reasoningDuration time.Duration
|
||||
|
||||
// messageRenderer renders assistant messages in standard mode.
|
||||
messageRenderer *MessageRenderer
|
||||
|
||||
@@ -172,7 +220,12 @@ func (s *StreamComponent) SetHeight(h int) {
|
||||
if h < 0 {
|
||||
h = 0
|
||||
}
|
||||
s.height = h
|
||||
if s.height != h {
|
||||
s.height = h
|
||||
// Invalidate cache — height clamp affects output.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears all accumulated state so the component is ready for the next
|
||||
@@ -181,16 +234,29 @@ func (s *StreamComponent) Reset() {
|
||||
s.phase = streamPhaseIdle
|
||||
s.spinning = false
|
||||
s.spinnerFrame = 0
|
||||
s.spinnerMsg = ""
|
||||
s.activeTools = nil
|
||||
s.streamContent.Reset()
|
||||
s.reasoningContent.Reset()
|
||||
s.pendingStream.Reset()
|
||||
s.pendingReasoning.Reset()
|
||||
s.flushPending = false
|
||||
s.renderCache = ""
|
||||
s.renderDirty = false
|
||||
s.timestamp = time.Time{}
|
||||
s.reasoningStartTime = time.Time{}
|
||||
s.reasoningDuration = 0
|
||||
}
|
||||
|
||||
// GetRenderedContent returns the rendered assistant message from the accumulated
|
||||
// streaming text. Returns empty string if no text has been accumulated. Used by
|
||||
// the parent AppModel to flush content via tea.Println() before resetting.
|
||||
//
|
||||
// This commits any pending chunks first so the output includes all received
|
||||
// content, not just what has been flushed by the tick.
|
||||
func (s *StreamComponent) GetRenderedContent() string {
|
||||
// Commit any pending chunks so the final output is complete.
|
||||
s.commitPending()
|
||||
|
||||
var sections []string
|
||||
|
||||
// Include rendered reasoning block if present.
|
||||
@@ -209,6 +275,21 @@ func (s *StreamComponent) GetRenderedContent() string {
|
||||
return strings.Join(sections, "\n")
|
||||
}
|
||||
|
||||
// commitPending moves any pending chunks to the committed content builders.
|
||||
// Called before reading content for scrollback output or on flush tick.
|
||||
func (s *StreamComponent) commitPending() {
|
||||
if s.pendingStream.Len() > 0 {
|
||||
s.streamContent.WriteString(s.pendingStream.String())
|
||||
s.pendingStream.Reset()
|
||||
s.renderDirty = true
|
||||
}
|
||||
if s.pendingReasoning.Len() > 0 {
|
||||
s.reasoningContent.WriteString(s.pendingReasoning.String())
|
||||
s.pendingReasoning.Reset()
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// tea.Model interface
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -227,6 +308,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.width = msg.Width
|
||||
s.messageRenderer.SetWidth(s.width)
|
||||
s.compactRenderer.SetWidth(s.width)
|
||||
// Invalidate render cache — width change affects wrapping/styling.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
|
||||
case streamSpinnerTickMsg:
|
||||
if s.spinning {
|
||||
@@ -250,24 +334,44 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
s.spinning = false
|
||||
}
|
||||
|
||||
case streamFlushTickMsg:
|
||||
s.flushPending = false
|
||||
s.commitPending()
|
||||
|
||||
case app.ReasoningChunkEvent:
|
||||
s.phase = streamPhaseActive
|
||||
if s.timestamp.IsZero() {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
s.reasoningContent.WriteString(msg.Delta)
|
||||
if s.reasoningStartTime.IsZero() {
|
||||
s.reasoningStartTime = time.Now()
|
||||
}
|
||||
s.pendingReasoning.WriteString(msg.Delta)
|
||||
if !s.flushPending {
|
||||
s.flushPending = true
|
||||
return s, streamFlushTickCmd()
|
||||
}
|
||||
|
||||
case app.StreamChunkEvent:
|
||||
s.phase = streamPhaseActive
|
||||
if s.timestamp.IsZero() {
|
||||
s.timestamp = time.Now()
|
||||
}
|
||||
s.streamContent.WriteString(msg.Content)
|
||||
// Freeze reasoning duration on transition from reasoning to streaming.
|
||||
if s.reasoningDuration == 0 && !s.reasoningStartTime.IsZero() {
|
||||
s.reasoningDuration = time.Since(s.reasoningStartTime)
|
||||
}
|
||||
s.pendingStream.WriteString(msg.Content)
|
||||
if !s.flushPending {
|
||||
s.flushPending = true
|
||||
return s, streamFlushTickCmd()
|
||||
}
|
||||
|
||||
case app.ToolExecutionEvent:
|
||||
if msg.IsStarting {
|
||||
// Show the tool name on the spinner while the tool executes.
|
||||
s.spinnerMsg = "Executing " + msg.ToolName + "…"
|
||||
// Add tool to active list for parallel execution display.
|
||||
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
|
||||
s.activeTools = append(s.activeTools, toolDisplay)
|
||||
s.spinnerFrame = 0
|
||||
if !s.spinning {
|
||||
s.phase = streamPhaseActive
|
||||
@@ -275,8 +379,9 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return s, streamSpinnerTickCmd()
|
||||
}
|
||||
} else {
|
||||
// Tool finished — clear execution label but keep spinning.
|
||||
s.spinnerMsg = ""
|
||||
// Tool finished — remove from active list but keep spinning if others remain.
|
||||
toolDisplay := formatToolExecutionMessage(msg.ToolName, msg.ToolArgs)
|
||||
s.activeTools = removeFromSlice(s.activeTools, toolDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,12 +397,20 @@ func (s *StreamComponent) View() tea.View {
|
||||
// Internal rendering
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// render builds the full content string for the stream region.
|
||||
// render builds the full content string for the stream region. Uses a render
|
||||
// cache to avoid redundant markdown re-parsing between flush ticks. The cache
|
||||
// is invalidated when committed content changes (flush tick), terminal width
|
||||
// changes, or height/thinking visibility changes.
|
||||
func (s *StreamComponent) render() string {
|
||||
if s.phase == streamPhaseIdle {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return cached render if committed content hasn't changed.
|
||||
if !s.renderDirty {
|
||||
return s.renderCache
|
||||
}
|
||||
|
||||
var sections []string
|
||||
|
||||
// Render reasoning/thinking block above the main text if present.
|
||||
@@ -313,6 +426,8 @@ func (s *StreamComponent) render() string {
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
s.renderCache = ""
|
||||
s.renderDirty = false
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -328,45 +443,86 @@ func (s *StreamComponent) render() string {
|
||||
}
|
||||
}
|
||||
|
||||
s.renderCache = content
|
||||
s.renderDirty = false
|
||||
return content
|
||||
}
|
||||
|
||||
// renderReasoningBlock renders the reasoning/thinking content. When thinking
|
||||
// is visible, the full reasoning text is shown in muted italic style. When
|
||||
// collapsed, a "Thinking..." label is shown instead.
|
||||
// renderReasoningBlock renders the reasoning/thinking content in a surface-tinted
|
||||
// box. When collapsed, shows the last 10 lines with a truncation hint. When
|
||||
// expanded, shows all lines. Includes a "Thought for Xs" duration footer.
|
||||
func (s *StreamComponent) renderReasoningBlock(reasoning string) string {
|
||||
theme := GetTheme()
|
||||
maxWidth := max(s.width-4, 20)
|
||||
|
||||
if !s.thinkingVisible {
|
||||
// Show collapsed "Thinking..." label.
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Italic(true).
|
||||
Render("Thinking...")
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(reasoning, "\n"), "\n")
|
||||
|
||||
// Render full reasoning text in muted italic style.
|
||||
style := lipgloss.NewStyle().
|
||||
contentStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Italic(true)
|
||||
|
||||
// Wrap to terminal width.
|
||||
maxWidth := s.width - 4 // leave some margin
|
||||
if maxWidth < 20 {
|
||||
maxWidth = 20
|
||||
var parts []string
|
||||
|
||||
// When collapsed and content exceeds 10 lines, show only the last 10
|
||||
// with a truncation hint (matching iteratr's thinking block pattern).
|
||||
const maxCollapsedLines = 10
|
||||
if !s.thinkingVisible && len(lines) > maxCollapsedLines {
|
||||
hidden := len(lines) - maxCollapsedLines
|
||||
hintStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.VeryMuted).
|
||||
Italic(true)
|
||||
parts = append(parts, hintStyle.Render(fmt.Sprintf("... (%d lines hidden)", hidden)))
|
||||
lines = lines[len(lines)-maxCollapsedLines:]
|
||||
}
|
||||
styled := style.Width(maxWidth).Render(reasoning)
|
||||
return styled
|
||||
|
||||
// Render reasoning text.
|
||||
parts = append(parts, contentStyle.Width(maxWidth).Render(strings.Join(lines, "\n")))
|
||||
|
||||
// Duration footer.
|
||||
var duration time.Duration
|
||||
if s.reasoningDuration > 0 {
|
||||
duration = s.reasoningDuration
|
||||
} else if !s.reasoningStartTime.IsZero() {
|
||||
duration = time.Since(s.reasoningStartTime)
|
||||
}
|
||||
if duration > 0 {
|
||||
var durationStr string
|
||||
if duration < time.Second {
|
||||
durationStr = fmt.Sprintf("%dms", duration.Milliseconds())
|
||||
} else {
|
||||
durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
|
||||
}
|
||||
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render("Thought for ") +
|
||||
lipgloss.NewStyle().Foreground(theme.Info).Render(durationStr)
|
||||
parts = append(parts, footer)
|
||||
}
|
||||
|
||||
innerContent := strings.Join(parts, "\n")
|
||||
|
||||
// Wrap in box with surface background for visual distinction.
|
||||
boxStyle := lipgloss.NewStyle().
|
||||
Background(theme.MutedBorder). // Surface0 (#313244)
|
||||
PaddingLeft(1).
|
||||
Width(maxWidth + 2).
|
||||
MarginBottom(1)
|
||||
|
||||
return boxStyle.Render(innerContent)
|
||||
}
|
||||
|
||||
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
|
||||
func (s *StreamComponent) SetThinkingVisible(visible bool) {
|
||||
s.thinkingVisible = visible
|
||||
if s.thinkingVisible != visible {
|
||||
s.thinkingVisible = visible
|
||||
// Invalidate cache — thinking visibility affects rendered output.
|
||||
s.renderCache = ""
|
||||
s.renderDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// HasReasoning returns true if any reasoning content has been accumulated.
|
||||
// HasReasoning returns true if any reasoning content has been accumulated
|
||||
// (committed or pending).
|
||||
func (s *StreamComponent) HasReasoning() bool {
|
||||
return s.reasoningContent.Len() > 0
|
||||
return s.reasoningContent.Len() > 0 || s.pendingReasoning.Len() > 0
|
||||
}
|
||||
|
||||
// SpinnerView returns the rendered spinner line for the parent to embed in the
|
||||
@@ -376,14 +532,22 @@ func (s *StreamComponent) SpinnerView() string {
|
||||
return ""
|
||||
}
|
||||
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
|
||||
if s.spinnerMsg == "" {
|
||||
if len(s.activeTools) == 0 {
|
||||
return " " + frame
|
||||
}
|
||||
theme := GetTheme()
|
||||
msgStyle := lipgloss.NewStyle().
|
||||
Foreground(theme.Text).
|
||||
Italic(true)
|
||||
return " " + frame + " " + msgStyle.Render(s.spinnerMsg)
|
||||
|
||||
// Format active tools list
|
||||
var toolsMsg string
|
||||
if len(s.activeTools) == 1 {
|
||||
toolsMsg = s.activeTools[0]
|
||||
} else {
|
||||
toolsMsg = "Running: " + strings.Join(s.activeTools, ", ")
|
||||
}
|
||||
return " " + frame + " " + msgStyle.Render(toolsMsg)
|
||||
}
|
||||
|
||||
// renderStreamingText renders the accumulated streaming text as a live assistant
|
||||
@@ -401,3 +565,22 @@ func (s *StreamComponent) renderStreamingText(text string) string {
|
||||
msg := s.messageRenderer.RenderAssistantMessage(text, ts, s.modelName)
|
||||
return msg.Content
|
||||
}
|
||||
|
||||
// removeFromSlice removes the first occurrence of a string from a slice.
|
||||
func removeFromSlice(slice []string, s string) []string {
|
||||
for i, v := range slice {
|
||||
if v == s {
|
||||
return append(slice[:i], slice[i+1:]...)
|
||||
}
|
||||
}
|
||||
return slice
|
||||
}
|
||||
|
||||
// formatToolExecutionMessage creates a descriptive spinner message for tool execution.
|
||||
// For spawn_subagent, it shows simply as "Subagent" with optional task preview.
|
||||
func formatToolExecutionMessage(toolName, toolArgs string) string {
|
||||
if toolName == "spawn_subagent" {
|
||||
return "Subagent"
|
||||
}
|
||||
return toolName
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ func renderToolBody(toolName, toolArgs, toolResult string, width int) string {
|
||||
if body := renderBashBody(toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
case toolName == "spawn_subagent":
|
||||
if body := renderSubagentBody(toolResult, width); body != "" {
|
||||
return body
|
||||
}
|
||||
}
|
||||
return "" // fall back to default
|
||||
}
|
||||
@@ -716,6 +720,8 @@ func renderToolBodyCompact(toolName, toolArgs, toolResult string, width int) str
|
||||
case toolName == "bash" || toolName == "run_shell_cmd" ||
|
||||
strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"):
|
||||
return renderBashCompact(toolResult, width)
|
||||
case toolName == "spawn_subagent":
|
||||
return renderSubagentCompact(toolResult)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -870,3 +876,121 @@ func renderBashCompact(toolResult string, width int) string {
|
||||
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Render(summary)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent tool renderers — show only summary, not full output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// renderSubagentBody renders a clean summary of subagent results.
|
||||
// Extracts timing/token info and shows only a brief summary instead of raw output.
|
||||
func renderSubagentBody(toolResult string, width int) string {
|
||||
theme := getTheme()
|
||||
result := strings.TrimSpace(toolResult)
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse the subagent result format:
|
||||
// "Subagent completed successfully in Xs. (tokens: N in / M out)\n\nResult:\n..."
|
||||
// or "Subagent failed (exit code X) after Ys.\n\nError: ...\n\nPartial output:\n..."
|
||||
|
||||
lines := strings.Split(result, "\n")
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// First line is always the status summary
|
||||
statusLine := lines[0]
|
||||
|
||||
// Build a clean summary
|
||||
var summary strings.Builder
|
||||
summary.WriteString(lipgloss.NewStyle().Foreground(theme.Muted).Render(statusLine))
|
||||
|
||||
// For successful results, extract a brief preview of the actual result
|
||||
if strings.Contains(statusLine, "successfully") {
|
||||
// Find where "Result:" starts and extract a preview
|
||||
if _, resultContent, found := strings.Cut(result, "Result:\n"); found {
|
||||
resultContent = strings.TrimSpace(resultContent)
|
||||
if resultContent != "" {
|
||||
// Show first 3 meaningful lines as preview
|
||||
preview := extractSubagentPreview(resultContent, 3, width-4)
|
||||
if preview != "" {
|
||||
summary.WriteString("\n\n")
|
||||
summary.WriteString(lipgloss.NewStyle().
|
||||
Foreground(theme.Muted).
|
||||
Italic(true).
|
||||
Render(preview))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary.String()
|
||||
}
|
||||
|
||||
// extractSubagentPreview extracts the first N non-empty lines from content,
|
||||
// truncating each line to maxWidth.
|
||||
func extractSubagentPreview(content string, maxLines, maxWidth int) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var preview []string
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Truncate long lines
|
||||
if len(trimmed) > maxWidth {
|
||||
trimmed = trimmed[:maxWidth-3] + "..."
|
||||
}
|
||||
preview = append(preview, trimmed)
|
||||
|
||||
if len(preview) >= maxLines {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(preview) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := strings.Join(preview, "\n")
|
||||
|
||||
// Count remaining lines for "more" indicator
|
||||
totalLines := 0
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
totalLines++
|
||||
}
|
||||
}
|
||||
if totalLines > maxLines {
|
||||
result += fmt.Sprintf("\n...(%d more lines)", totalLines-maxLines)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// renderSubagentCompact returns a brief one-line summary for subagent results.
|
||||
func renderSubagentCompact(toolResult string) string {
|
||||
result := strings.TrimSpace(toolResult)
|
||||
if result == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
theme := getTheme()
|
||||
|
||||
// Extract just the first line which contains the status
|
||||
lines := strings.Split(result, "\n")
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
statusLine := lines[0]
|
||||
|
||||
// Make it more compact by removing redundant words
|
||||
statusLine = strings.Replace(statusLine, "Subagent completed successfully in ", "Completed in ", 1)
|
||||
statusLine = strings.Replace(statusLine, "Subagent failed", "Failed", 1)
|
||||
|
||||
return lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render(statusLine)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestUsageTracker_OAuthCosts(t *testing.T) {
|
||||
stats := regularTracker.GetLastRequestStats()
|
||||
if stats == nil {
|
||||
t.Fatal("Expected stats to be non-nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Check that costs are calculated for regular API key
|
||||
@@ -48,6 +49,7 @@ func TestUsageTracker_OAuthCosts(t *testing.T) {
|
||||
oauthStats := oauthTracker.GetLastRequestStats()
|
||||
if oauthStats == nil {
|
||||
t.Fatal("Expected OAuth stats to be non-nil")
|
||||
return
|
||||
}
|
||||
|
||||
// Check that all costs are $0 for OAuth
|
||||
|
||||
+96
-11
@@ -1,6 +1,9 @@
|
||||
package kit
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event types
|
||||
@@ -48,6 +51,54 @@ type Event interface {
|
||||
EventType() EventType
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool kind constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ToolKind constants classify what a tool does, enabling UIs to render
|
||||
// appropriate visualizations (e.g. diff view for edit tools, command+output
|
||||
// for execute tools) and file trackers to identify which results contain
|
||||
// modifications.
|
||||
const (
|
||||
ToolKindExecute = "execute" // Shell execution (bash)
|
||||
ToolKindEdit = "edit" // File modification (edit, write)
|
||||
ToolKindRead = "read" // File reading (read, ls)
|
||||
ToolKindSearch = "search" // Content/file search (grep, find)
|
||||
ToolKindSubagent = "agent" // Subagent spawning (spawn_subagent)
|
||||
)
|
||||
|
||||
// coreToolKinds maps built-in tool names to their kind. MCP and extension
|
||||
// tools without an entry default to ToolKindExecute.
|
||||
var coreToolKinds = map[string]string{
|
||||
"bash": ToolKindExecute,
|
||||
"edit": ToolKindEdit,
|
||||
"write": ToolKindEdit,
|
||||
"read": ToolKindRead,
|
||||
"ls": ToolKindRead,
|
||||
"grep": ToolKindSearch,
|
||||
"find": ToolKindSearch,
|
||||
"spawn_subagent": ToolKindSubagent,
|
||||
}
|
||||
|
||||
// toolKindFor returns the ToolKind for a given tool name, defaulting to
|
||||
// ToolKindExecute for unknown tools.
|
||||
func toolKindFor(toolName string) string {
|
||||
if kind, ok := coreToolKinds[toolName]; ok {
|
||||
return kind
|
||||
}
|
||||
return ToolKindExecute
|
||||
}
|
||||
|
||||
// parseToolArgs attempts to parse a JSON-encoded tool args string into a map.
|
||||
// Returns nil on failure (non-fatal convenience parsing).
|
||||
func parseToolArgs(toolArgs string) map[string]any {
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal([]byte(toolArgs), &parsed) == nil {
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concrete event structs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -62,8 +113,9 @@ func (e TurnStartEvent) EventType() EventType { return EventTurnStart }
|
||||
|
||||
// TurnEndEvent fires after the agent finishes processing.
|
||||
type TurnEndEvent struct {
|
||||
Response string
|
||||
Error error
|
||||
Response string
|
||||
Error error
|
||||
StopReason string // "end_turn", "max_tokens", "tool_use", "error", etc.
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -101,8 +153,11 @@ func (e MessageEndEvent) EventType() EventType { return EventMessageEnd }
|
||||
|
||||
// ToolCallEvent fires when a tool call has been parsed.
|
||||
type ToolCallEvent struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
ToolCallID string // Stable ID for correlating tool lifecycle events
|
||||
ToolName string
|
||||
ToolKind string // Tool classification: "execute", "edit", "read", "search", "agent"
|
||||
ToolArgs string // JSON-encoded arguments
|
||||
ParsedArgs map[string]any // Pre-parsed arguments for convenience (nil on parse failure)
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -110,7 +165,10 @@ func (e ToolCallEvent) EventType() EventType { return EventToolCall }
|
||||
|
||||
// ToolExecutionStartEvent fires when a tool begins executing.
|
||||
type ToolExecutionStartEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
ToolArgs string
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -118,7 +176,9 @@ func (e ToolExecutionStartEvent) EventType() EventType { return EventToolExecuti
|
||||
|
||||
// ToolExecutionEndEvent fires when a tool finishes executing.
|
||||
type ToolExecutionEndEvent struct {
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
@@ -126,10 +186,35 @@ func (e ToolExecutionEndEvent) EventType() EventType { return EventToolExecution
|
||||
|
||||
// ToolResultEvent fires after a tool execution completes with its result.
|
||||
type ToolResultEvent struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Result string
|
||||
IsError bool
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolKind string
|
||||
ToolArgs string
|
||||
ParsedArgs map[string]any // Pre-parsed arguments for convenience
|
||||
Result string
|
||||
IsError bool
|
||||
Metadata *ToolResultMetadata // Optional structured metadata from tool execution
|
||||
}
|
||||
|
||||
// ToolResultMetadata carries structured data from tool executions.
|
||||
type ToolResultMetadata struct {
|
||||
FileDiffs []FileDiffInfo `json:"file_diffs,omitempty"` // Present for edit/write tools
|
||||
SubagentSessionID string `json:"subagent_session_id,omitempty"` // Present for spawn_subagent tool
|
||||
}
|
||||
|
||||
// FileDiffInfo describes a file modification from an edit or write tool.
|
||||
type FileDiffInfo struct {
|
||||
Path string `json:"path"` // Absolute file path
|
||||
Additions int `json:"additions"` // Lines added
|
||||
Deletions int `json:"deletions"` // Lines removed
|
||||
IsNew bool `json:"is_new,omitempty"` // True if file was created (write only)
|
||||
DiffBlocks []DiffBlock `json:"diff_blocks,omitempty"`
|
||||
}
|
||||
|
||||
// DiffBlock represents a single old→new text replacement within a file.
|
||||
type DiffBlock struct {
|
||||
OldText string `json:"old_text"`
|
||||
NewText string `json:"new_text"`
|
||||
}
|
||||
|
||||
// EventType implements Event.
|
||||
|
||||
@@ -89,11 +89,13 @@ func (m *Kit) bridgeExtensions(runner *extensions.Runner) {
|
||||
if runner.HasHandlers(extensions.AgentEnd) {
|
||||
m.Subscribe(func(e Event) {
|
||||
if ev, ok := e.(TurnEndEvent); ok {
|
||||
stopReason := "completed"
|
||||
stopReason := ev.StopReason
|
||||
response := ev.Response
|
||||
if ev.Error != nil {
|
||||
stopReason = "error"
|
||||
response = ""
|
||||
} else if stopReason == "" {
|
||||
stopReason = "completed"
|
||||
}
|
||||
_, _ = runner.Emit(extensions.AgentEndEvent{
|
||||
Response: response,
|
||||
|
||||
+16
-12
@@ -31,8 +31,9 @@ const (
|
||||
|
||||
// BeforeToolCallHook is the input for hooks that fire before a tool executes.
|
||||
type BeforeToolCallHook struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
}
|
||||
|
||||
// BeforeToolCallResult controls whether the tool call proceeds.
|
||||
@@ -43,10 +44,11 @@ type BeforeToolCallResult struct {
|
||||
|
||||
// AfterToolResultHook is the input for hooks that fire after a tool executes.
|
||||
type AfterToolResultHook struct {
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Result string
|
||||
IsError bool
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
ToolArgs string
|
||||
Result string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// AfterToolResultResult can modify the tool's output before it reaches the LLM.
|
||||
@@ -258,8 +260,9 @@ func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.To
|
||||
// 1. BeforeToolCall — can block execution.
|
||||
if h.beforeToolCall.hasHooks() {
|
||||
if result := h.beforeToolCall.run(BeforeToolCallHook{
|
||||
ToolName: toolName,
|
||||
ToolArgs: call.Input,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: toolName,
|
||||
ToolArgs: call.Input,
|
||||
}); result != nil && result.Block {
|
||||
reason := result.Reason
|
||||
if reason == "" {
|
||||
@@ -276,10 +279,11 @@ func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.To
|
||||
// 3. AfterToolResult — can modify output.
|
||||
if h.afterToolResult.hasHooks() {
|
||||
if result := h.afterToolResult.run(AfterToolResultHook{
|
||||
ToolName: toolName,
|
||||
ToolArgs: call.Input,
|
||||
Result: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: toolName,
|
||||
ToolArgs: call.Input,
|
||||
Result: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
}); result != nil {
|
||||
if result.Result != nil {
|
||||
resp.Content = *result.Result
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestHookRegistry_RegisterAndRun(t *testing.T) {
|
||||
got := hr.run("hello")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if *got != "handled: hello" {
|
||||
t.Errorf("expected 'handled: hello', got %q", *got)
|
||||
@@ -51,6 +52,7 @@ func TestHookRegistry_FirstNonNilWins(t *testing.T) {
|
||||
got := hr.run("test")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if *got != "second: test" {
|
||||
t.Errorf("expected 'second: test', got %q", *got)
|
||||
@@ -77,6 +79,7 @@ func TestHookRegistry_PriorityOrdering(t *testing.T) {
|
||||
got := hr.run("x")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if *got != "high" {
|
||||
t.Errorf("expected 'high' (priority 0 runs first), got %q", *got)
|
||||
@@ -441,6 +444,7 @@ func TestBeforeTurnHook_PromptOverride(t *testing.T) {
|
||||
result := hr.run(BeforeTurnHook{Prompt: "original"})
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if result.Prompt == nil || *result.Prompt != "modified prompt" {
|
||||
t.Errorf("expected prompt override, got %v", result.Prompt)
|
||||
@@ -462,6 +466,7 @@ func TestBeforeTurnHook_InjectSystemAndContext(t *testing.T) {
|
||||
result := hr.run(BeforeTurnHook{Prompt: "hello"})
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return
|
||||
}
|
||||
if result.SystemPrompt == nil || *result.SystemPrompt != "be concise" {
|
||||
t.Errorf("expected system prompt injection")
|
||||
|
||||
+302
-16
@@ -2,6 +2,7 @@ package kit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
"github.com/mark3labs/kit/internal/agent"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/core"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/kitsetup"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
@@ -347,6 +349,50 @@ func (m *Kit) GetSessionMessages() []extensions.SessionMessage {
|
||||
return msgs
|
||||
}
|
||||
|
||||
// StructuredMessage represents a conversation message with typed content parts
|
||||
// (tool calls, reasoning, finish markers, etc.) instead of flattened text.
|
||||
type StructuredMessage struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Role MessageRole
|
||||
Parts []ContentPart
|
||||
Model string
|
||||
Provider string
|
||||
Timestamp string // RFC3339 format
|
||||
}
|
||||
|
||||
// GetStructuredMessages returns the conversation messages on the current
|
||||
// branch with full typed content parts. Unlike GetSessionMessages() which
|
||||
// flattens all content to a single text string, this preserves tool calls,
|
||||
// tool results, reasoning blocks, and finish markers as distinct typed parts.
|
||||
func (m *Kit) GetStructuredMessages() []StructuredMessage {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
branch := m.treeSession.GetBranch("")
|
||||
var msgs []StructuredMessage
|
||||
for _, entry := range branch {
|
||||
me, ok := entry.(*session.MessageEntry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msg, err := me.ToMessage()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, StructuredMessage{
|
||||
ID: me.ID,
|
||||
ParentID: me.ParentID,
|
||||
Role: msg.Role,
|
||||
Parts: msg.Parts,
|
||||
Model: msg.Model,
|
||||
Provider: msg.Provider,
|
||||
Timestamp: me.Timestamp.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// GetSessionFilePath returns the JSONL file path of the current session.
|
||||
func (m *Kit) GetSessionFilePath() string {
|
||||
if m.treeSession == nil {
|
||||
@@ -849,11 +895,19 @@ func InitTreeSession(opts *Options) (*session.TreeManager, error) {
|
||||
// New creates a Kit instance using the same initialization as the CLI.
|
||||
// It loads configuration, initializes MCP servers, creates the LLM model, and
|
||||
// sets up the agent for interaction. Returns an error if initialization fails.
|
||||
// viperInitMu serializes viper writes during kit.New(). Viper's global state
|
||||
// is not thread-safe, so concurrent calls (e.g. parallel subagent spawns)
|
||||
// must not overlap the Set()/Get() window.
|
||||
var viperInitMu sync.Mutex
|
||||
|
||||
func New(ctx context.Context, opts *Options) (*Kit, error) {
|
||||
if opts == nil {
|
||||
opts = &Options{}
|
||||
}
|
||||
|
||||
viperInitMu.Lock()
|
||||
defer viperInitMu.Unlock()
|
||||
|
||||
// Set CLI-equivalent defaults for viper. When used as an SDK (without
|
||||
// cobra), these defaults are not registered via flag bindings.
|
||||
setSDKDefaults()
|
||||
@@ -1150,6 +1204,14 @@ type TurnResult struct {
|
||||
// Response is the assistant's final text response.
|
||||
Response string
|
||||
|
||||
// StopReason indicates why the turn ended. Derived from the LLM
|
||||
// provider's finish reason: "stop", "length" (max tokens), "tool-calls",
|
||||
// "content-filter", "error", "other", "unknown".
|
||||
StopReason string
|
||||
|
||||
// SessionID is the UUID of the session this turn belongs to.
|
||||
SessionID string
|
||||
|
||||
// TotalUsage is the aggregate token usage across all steps in the turn
|
||||
// (includes tool-calling loop iterations). Nil if the provider didn't
|
||||
// report usage.
|
||||
@@ -1165,6 +1227,168 @@ type TurnResult struct {
|
||||
Messages []FantasyMessage
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-process subagent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentConfig configures an in-process subagent spawned via Kit.Subagent().
|
||||
type SubagentConfig struct {
|
||||
// Prompt is the task/instruction for the subagent (required).
|
||||
Prompt string
|
||||
|
||||
// Model overrides the parent's model (e.g. "anthropic/claude-haiku-3-5-20241022").
|
||||
// Empty string uses the parent's current model.
|
||||
Model string
|
||||
|
||||
// SystemPrompt provides domain-specific instructions for the subagent.
|
||||
// Empty string uses a minimal default prompt.
|
||||
SystemPrompt string
|
||||
|
||||
// Tools overrides the tool set. If nil, SubagentTools() is used (all
|
||||
// core tools except spawn_subagent, preventing infinite recursion).
|
||||
Tools []Tool
|
||||
|
||||
// NoSession, when true, uses an in-memory ephemeral session. When false
|
||||
// (default), the subagent's session is persisted and can be loaded for
|
||||
// replay/inspection.
|
||||
NoSession bool
|
||||
|
||||
// Timeout limits execution time. Zero means 5 minute default.
|
||||
Timeout time.Duration
|
||||
|
||||
// OnEvent, when set, receives all events from the subagent's event bus.
|
||||
// This enables the parent to stream subagent tool calls, text chunks,
|
||||
// etc. in real time.
|
||||
OnEvent func(Event)
|
||||
}
|
||||
|
||||
// SubagentResult contains the outcome of an in-process subagent execution.
|
||||
type SubagentResult struct {
|
||||
// Response is the subagent's final text response.
|
||||
Response string
|
||||
// Error is set if the subagent failed (nil on success).
|
||||
Error error
|
||||
// SessionID is the subagent's session identifier (for replay).
|
||||
SessionID string
|
||||
// StopReason is the LLM's finish reason for the subagent's final turn.
|
||||
StopReason string
|
||||
// Usage contains token usage from the subagent's run.
|
||||
Usage *FantasyUsage
|
||||
// Elapsed is the total execution time.
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// Subagent spawns an in-process child Kit instance to perform a task. The
|
||||
// child gets its own session, event bus, and agent loop but shares the
|
||||
// parent's config (API keys, provider settings) and defaults to the parent's
|
||||
// model when SubagentConfig.Model is empty.
|
||||
//
|
||||
// This is the recommended way to run subagents in the SDK — no subprocess,
|
||||
// no kit binary dependency, native Go types for results.
|
||||
func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult, error) {
|
||||
if cfg.Prompt == "" {
|
||||
return nil, fmt.Errorf("subagent prompt is required")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Default timeout.
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// Resolve model: fall back to parent's model, and inherit the parent's
|
||||
// provider when only a bare model name is given (e.g. "claude-haiku"
|
||||
// instead of "anthropic/claude-haiku"). This avoids provider guessing.
|
||||
model := cfg.Model
|
||||
if model == "" {
|
||||
model = m.modelString
|
||||
} else if !strings.Contains(model, "/") {
|
||||
// Bare model name — prepend parent's provider.
|
||||
if parts := strings.SplitN(m.modelString, "/", 2); len(parts) == 2 {
|
||||
model = parts[0] + "/" + model
|
||||
}
|
||||
}
|
||||
|
||||
// Default system prompt.
|
||||
systemPrompt := cfg.SystemPrompt
|
||||
if systemPrompt == "" {
|
||||
systemPrompt = "You are a helpful coding assistant. Complete the task efficiently and thoroughly."
|
||||
}
|
||||
|
||||
// Default tools: everything except spawn_subagent.
|
||||
tools := cfg.Tools
|
||||
if tools == nil {
|
||||
tools = SubagentTools()
|
||||
}
|
||||
|
||||
// Create child Kit instance. If the requested model fails (bad name,
|
||||
// unsupported provider, etc.), fall back to the parent's model so the
|
||||
// agent gets a useful error message instead of a hard failure.
|
||||
childOpts := &Options{
|
||||
Model: model,
|
||||
SystemPrompt: systemPrompt,
|
||||
Tools: tools,
|
||||
NoSession: cfg.NoSession,
|
||||
Quiet: true,
|
||||
}
|
||||
child, err := New(ctx, childOpts)
|
||||
if err != nil && model != m.modelString {
|
||||
// Model-specific failure — retry with parent's model.
|
||||
childOpts.Model = m.modelString
|
||||
child, err = New(ctx, childOpts)
|
||||
if err != nil {
|
||||
return &SubagentResult{
|
||||
Error: fmt.Errorf("failed to create subagent: %w", err),
|
||||
Elapsed: time.Since(start),
|
||||
}, err
|
||||
}
|
||||
// Prepend a note so the agent knows which model is actually running.
|
||||
cfg.Prompt = fmt.Sprintf(
|
||||
"[Note: requested model %q was not available, using %s instead.]\n\n%s",
|
||||
model, m.modelString, cfg.Prompt,
|
||||
)
|
||||
} else if err != nil {
|
||||
return &SubagentResult{
|
||||
Error: fmt.Errorf("failed to create subagent: %w", err),
|
||||
Elapsed: time.Since(start),
|
||||
}, err
|
||||
}
|
||||
defer func() { _ = child.Close() }()
|
||||
|
||||
// Forward events to parent if requested.
|
||||
if cfg.OnEvent != nil {
|
||||
child.Subscribe(cfg.OnEvent)
|
||||
}
|
||||
|
||||
// Run the prompt.
|
||||
result, err := child.PromptResult(ctx, cfg.Prompt)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return &SubagentResult{
|
||||
Error: err,
|
||||
SessionID: child.GetSessionID(),
|
||||
Elapsed: elapsed,
|
||||
}, err
|
||||
}
|
||||
|
||||
subResult := &SubagentResult{
|
||||
Response: result.Response,
|
||||
SessionID: child.GetSessionID(),
|
||||
StopReason: result.StopReason,
|
||||
Elapsed: elapsed,
|
||||
}
|
||||
if result.TotalUsage != nil {
|
||||
subResult.Usage = result.TotalUsage
|
||||
}
|
||||
|
||||
return subResult, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared generation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1173,22 +1397,64 @@ type TurnResult struct {
|
||||
// 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) {
|
||||
// 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).
|
||||
ctx = core.WithSubagentSpawner(ctx, func(
|
||||
spawnCtx context.Context, prompt, model, systemPrompt string, timeout time.Duration,
|
||||
) (*core.SubagentSpawnResult, error) {
|
||||
result, err := m.Subagent(spawnCtx, SubagentConfig{
|
||||
Prompt: prompt,
|
||||
Model: model,
|
||||
SystemPrompt: systemPrompt,
|
||||
Timeout: timeout,
|
||||
OnEvent: func(e Event) {
|
||||
m.events.emit(e)
|
||||
},
|
||||
})
|
||||
if result == nil {
|
||||
return &core.SubagentSpawnResult{Error: err}, err
|
||||
}
|
||||
sr := &core.SubagentSpawnResult{
|
||||
Response: result.Response,
|
||||
Error: result.Error,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
sr.InputTokens = result.Usage.InputTokens
|
||||
sr.OutputTokens = result.Usage.OutputTokens
|
||||
}
|
||||
return sr, err
|
||||
})
|
||||
|
||||
return m.agent.GenerateWithLoopAndStreaming(ctx, messages,
|
||||
func(toolName, toolArgs string) {
|
||||
m.events.emit(ToolCallEvent{ToolName: toolName, ToolArgs: toolArgs})
|
||||
func(toolCallID, toolName, toolArgs string) {
|
||||
m.events.emit(ToolCallEvent{
|
||||
ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName),
|
||||
ToolArgs: toolArgs, ParsedArgs: parseToolArgs(toolArgs),
|
||||
})
|
||||
},
|
||||
func(toolName string, isStarting bool) {
|
||||
func(toolCallID, toolName, toolArgs string, isStarting bool) {
|
||||
if isStarting {
|
||||
m.events.emit(ToolExecutionStartEvent{ToolName: toolName})
|
||||
m.events.emit(ToolExecutionStartEvent{ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName), ToolArgs: toolArgs})
|
||||
} else {
|
||||
m.events.emit(ToolExecutionEndEvent{ToolName: toolName})
|
||||
m.events.emit(ToolExecutionEndEvent{ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName)})
|
||||
}
|
||||
},
|
||||
func(toolName, toolArgs, resultText string, isError bool) {
|
||||
m.events.emit(ToolResultEvent{
|
||||
ToolName: toolName, ToolArgs: toolArgs,
|
||||
func(toolCallID, toolName, toolArgs, resultText, metadata string, isError bool) {
|
||||
evt := ToolResultEvent{
|
||||
ToolCallID: toolCallID, ToolName: toolName, ToolKind: toolKindFor(toolName),
|
||||
ToolArgs: toolArgs, ParsedArgs: parseToolArgs(toolArgs),
|
||||
Result: resultText, IsError: isError,
|
||||
})
|
||||
}
|
||||
if metadata != "" {
|
||||
var meta ToolResultMetadata
|
||||
if err := json.Unmarshal([]byte(metadata), &meta); err == nil {
|
||||
evt.Metadata = &meta
|
||||
}
|
||||
}
|
||||
m.events.emit(evt)
|
||||
},
|
||||
func(content string) {
|
||||
m.events.emit(ResponseEvent{Content: content})
|
||||
@@ -1222,10 +1488,12 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
// <skill> block, and appends any trailing user args.
|
||||
if expanded := m.expandSkillCommand(prompt); expanded != prompt {
|
||||
prompt = expanded
|
||||
// Replace the last user message in preMessages with the expanded text.
|
||||
// Replace the last user message in preMessages with the expanded text,
|
||||
// preserving any file parts (e.g. clipboard images).
|
||||
for i := len(preMessages) - 1; i >= 0; i-- {
|
||||
if preMessages[i].Role == fantasy.MessageRoleUser {
|
||||
preMessages[i] = fantasy.NewUserMessage(expanded)
|
||||
files := extractFileParts(preMessages[i])
|
||||
preMessages[i] = fantasy.NewUserMessage(expanded, files...)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1234,11 +1502,13 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
// Run BeforeTurn hooks — can modify the prompt, inject system/context messages.
|
||||
if m.beforeTurn.hasHooks() {
|
||||
if hookResult := m.beforeTurn.run(BeforeTurnHook{Prompt: prompt}); hookResult != nil {
|
||||
// Override prompt text in the last user message.
|
||||
// Override prompt text in the last user message, preserving
|
||||
// any file parts (e.g. clipboard images).
|
||||
if hookResult.Prompt != nil {
|
||||
for i := len(preMessages) - 1; i >= 0; i-- {
|
||||
if preMessages[i].Role == fantasy.MessageRoleUser {
|
||||
preMessages[i] = fantasy.NewUserMessage(*hookResult.Prompt)
|
||||
files := extractFileParts(preMessages[i])
|
||||
preMessages[i] = fantasy.NewUserMessage(*hookResult.Prompt, files...)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1313,8 +1583,10 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
m.lastInputTokensMu.Unlock()
|
||||
}
|
||||
|
||||
stopReason := result.StopReason
|
||||
|
||||
m.events.emit(MessageEndEvent{Content: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText})
|
||||
m.events.emit(TurnEndEvent{Response: responseText, StopReason: stopReason})
|
||||
|
||||
// Run AfterTurn hooks.
|
||||
if m.afterTurn.hasHooks() {
|
||||
@@ -1323,8 +1595,10 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
|
||||
|
||||
// Build TurnResult with usage stats.
|
||||
turnResult := &TurnResult{
|
||||
Response: responseText,
|
||||
Messages: result.ConversationMessages,
|
||||
Response: responseText,
|
||||
StopReason: stopReason,
|
||||
SessionID: m.GetSessionID(),
|
||||
Messages: result.ConversationMessages,
|
||||
}
|
||||
totalUsage := result.TotalUsage
|
||||
turnResult.TotalUsage = &totalUsage
|
||||
@@ -1527,6 +1801,18 @@ func (m *Kit) GetTools() []Tool {
|
||||
return m.agent.GetTools()
|
||||
}
|
||||
|
||||
// extractFileParts returns all FilePart entries from a message's Content.
|
||||
// Used to preserve image attachments when replacing user message text.
|
||||
func extractFileParts(msg fantasy.Message) []fantasy.FilePart {
|
||||
var files []fantasy.FilePart
|
||||
for _, part := range msg.Content {
|
||||
if fp, ok := part.(fantasy.FilePart); ok {
|
||||
files = append(files, fp)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// Close cleans up resources including MCP server connections, model resources,
|
||||
// and the tree session file handle. Should be called when the Kit instance is
|
||||
// no longer needed. Returns an error if cleanup fails.
|
||||
|
||||
@@ -51,3 +51,8 @@ func CodingTools(opts ...ToolOption) []Tool { return core.CodingTools(opts...) }
|
||||
// ReadOnlyTools returns tools for read-only exploration:
|
||||
// read, grep, find, ls.
|
||||
func ReadOnlyTools(opts ...ToolOption) []Tool { return core.ReadOnlyTools(opts...) }
|
||||
|
||||
// SubagentTools returns all core tools except spawn_subagent. Use this when
|
||||
// creating child Kit instances (in-process subagents) to prevent infinite
|
||||
// recursion.
|
||||
func SubagentTools(opts ...ToolOption) []Tool { return core.SubagentTools(opts...) }
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"source": "davis7dotsh/better-context",
|
||||
"sourceType": "github",
|
||||
"computedHash": "99bc5301f4f839a6f3be99d98955f32f1cd576c218731fa05fa54a003bd20e9b"
|
||||
},
|
||||
"kit-extensions": {
|
||||
"source": "mark3labs/kit",
|
||||
"sourceType": "github",
|
||||
"computedHash": "9347a88bec46dd52727a672b6c8d058955f9f50dfe98708e0c63b85e0779ba96"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
---
|
||||
name: kit-extensions
|
||||
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
|
||||
---
|
||||
|
||||
# Kit Extensions Development Guide
|
||||
|
||||
Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more.
|
||||
|
||||
## Extension Structure
|
||||
|
||||
Every extension must export a `package main` with an `Init(api ext.API)` function:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register event handlers, tools, commands, etc.
|
||||
}
|
||||
```
|
||||
|
||||
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
|
||||
|
||||
## Extension Locations
|
||||
|
||||
Extensions are auto-loaded from these directories:
|
||||
|
||||
- `~/.config/kit/extensions/*.go` (global, single files)
|
||||
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
|
||||
- `.kit/extensions/*.go` (project-local, single files)
|
||||
- `.kit/extensions/*/main.go` (project-local, subdirectories)
|
||||
|
||||
Or loaded explicitly:
|
||||
|
||||
```bash
|
||||
kit -e path/to/extension.go
|
||||
kit --extension path/to/extension.go
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
|
||||
|
||||
## API Overview
|
||||
|
||||
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Events
|
||||
|
||||
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
|
||||
|
||||
### Session Events
|
||||
|
||||
```go
|
||||
// Fired when session is loaded/created.
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
// e.SessionID string
|
||||
})
|
||||
|
||||
// Fired when Kit is shutting down. Use for cleanup.
|
||||
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
// No fields.
|
||||
})
|
||||
```
|
||||
|
||||
### Agent Turn Events
|
||||
|
||||
```go
|
||||
// Before agent starts processing. Can inject system prompt or text.
|
||||
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
// e.Prompt string
|
||||
// Return nil to pass through.
|
||||
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
|
||||
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Agent loop has started.
|
||||
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
|
||||
// e.Prompt string
|
||||
})
|
||||
|
||||
// Agent finished responding.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// e.Response string
|
||||
// e.StopReason string — "completed", "cancelled", "error"
|
||||
})
|
||||
```
|
||||
|
||||
### Tool Events
|
||||
|
||||
```go
|
||||
// Before a tool executes. Can block the call.
|
||||
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
// e.ToolName string
|
||||
// e.ToolCallID string
|
||||
// e.Input string — JSON-encoded parameters
|
||||
// e.Source string — "llm" or "user"
|
||||
// Return nil to allow.
|
||||
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Tool execution started (informational only).
|
||||
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// Tool execution ended (informational only).
|
||||
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// After a tool returns. Can modify the result.
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
// e.ToolName string
|
||||
// e.Input string
|
||||
// e.Content string
|
||||
// e.IsError bool
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ToolResultResult{Content: &s} to replace content.
|
||||
// Return &ext.ToolResultResult{IsError: &b} to change error status.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Input Events
|
||||
|
||||
```go
|
||||
// User submitted input. Can handle or transform it.
|
||||
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
// e.Text string
|
||||
// e.Source string — "interactive", "cli", "script", "queue"
|
||||
// Return nil to pass through to agent.
|
||||
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
|
||||
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming Events
|
||||
|
||||
```go
|
||||
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
|
||||
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
|
||||
// e.Chunk string — streaming text chunk
|
||||
})
|
||||
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
|
||||
// e.Content string — full message content
|
||||
})
|
||||
```
|
||||
|
||||
### Model Events
|
||||
|
||||
```go
|
||||
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
|
||||
// e.NewModel string
|
||||
// e.PreviousModel string
|
||||
// e.Source string — "extension" or "user"
|
||||
})
|
||||
```
|
||||
|
||||
### Context Filtering
|
||||
|
||||
```go
|
||||
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
// e.Messages []ext.ContextMessage
|
||||
// Each ContextMessage has: Index int, Role string, Content string
|
||||
// Index -1 means a new injected message (not from session).
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Session Control Events
|
||||
|
||||
```go
|
||||
// Before forking the session tree. Can cancel.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
// e.TargetID string, e.IsUserMessage bool, e.UserText string
|
||||
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before switching/clearing session. Can cancel.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
// e.Reason string — "new" or "clear"
|
||||
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before context compaction. Can cancel.
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
// e.EstimatedTokens, e.ContextLimit int
|
||||
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
|
||||
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Events
|
||||
|
||||
```go
|
||||
// Subscribe to custom events emitted by other extensions.
|
||||
api.OnCustomEvent("event-name", func(data string) {
|
||||
// data is arbitrary string payload
|
||||
})
|
||||
|
||||
// Emit from Context:
|
||||
ctx.EmitCustomEvent("event-name", "payload")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Tools
|
||||
|
||||
Tools are functions the LLM can invoke:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "current_time",
|
||||
Description: "Get the current date and time",
|
||||
Parameters: `{"type":"object","properties":{}}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
return time.Now().Format(time.RFC3339), nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
For long-running tools with cancellation and progress:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "slow_task",
|
||||
Description: "A long-running task with progress reporting",
|
||||
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
|
||||
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
if tc.IsCancelled() {
|
||||
return "cancelled", nil
|
||||
}
|
||||
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return "done", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
|
||||
|
||||
---
|
||||
|
||||
## Registering Slash Commands
|
||||
|
||||
Commands are user-facing actions invoked with `/name` in the input:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "echo",
|
||||
Description: "Echo back the provided text",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.PrintInfo("You said: " + args)
|
||||
return "", nil
|
||||
},
|
||||
// Optional tab-completion:
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
return []string{"hello", "world"}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
|
||||
|
||||
---
|
||||
|
||||
## Registering Keyboard Shortcuts
|
||||
|
||||
```go
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan mode",
|
||||
}, func(ctx ext.Context) {
|
||||
// handler runs when shortcut is pressed
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Options
|
||||
|
||||
Options are configurable values resolved from env vars, config, or defaults:
|
||||
|
||||
```go
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "my-setting",
|
||||
Description: "Controls something",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
|
||||
val := ctx.GetOption("my-setting")
|
||||
|
||||
// Set at runtime:
|
||||
ctx.SetOption("my-setting", "true")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context API Reference
|
||||
|
||||
The `ext.Context` struct provides runtime capabilities via function fields.
|
||||
|
||||
### Output
|
||||
|
||||
```go
|
||||
ctx.Print("plain text") // plain output
|
||||
ctx.PrintInfo("styled info block") // bordered info block
|
||||
ctx.PrintError("styled error block") // red error block
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
|
||||
Text: "content",
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "my-ext",
|
||||
})
|
||||
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
|
||||
```
|
||||
|
||||
### Message Injection
|
||||
|
||||
```go
|
||||
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
|
||||
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
|
||||
```
|
||||
|
||||
### Widgets
|
||||
|
||||
Persistent UI elements displayed above or below the input area:
|
||||
|
||||
```go
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "my-widget",
|
||||
Placement: ext.WidgetAbove, // or ext.WidgetBelow
|
||||
Content: ext.WidgetContent{
|
||||
Text: "Status: Active",
|
||||
Markdown: false, // set true for markdown rendering
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1", // hex color
|
||||
NoBorder: false,
|
||||
},
|
||||
Priority: 0, // lower values render first
|
||||
})
|
||||
|
||||
ctx.RemoveWidget("my-widget")
|
||||
```
|
||||
|
||||
### Header and Footer
|
||||
|
||||
```go
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Header"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
ctx.RemoveHeader()
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Footer"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
ctx.RemoveFooter()
|
||||
```
|
||||
|
||||
### Status Bar
|
||||
|
||||
```go
|
||||
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
|
||||
ctx.RemoveStatus("key")
|
||||
```
|
||||
|
||||
### Interactive Prompts
|
||||
|
||||
These block until the user responds (safe in slash commands and goroutines):
|
||||
|
||||
```go
|
||||
// Selection list
|
||||
result := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Pick one:",
|
||||
Options: []string{"Option A", "Option B", "Option C"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string, result.Index int
|
||||
}
|
||||
|
||||
// Yes/No confirmation
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Are you sure?",
|
||||
DefaultValue: false,
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value bool
|
||||
}
|
||||
|
||||
// Text input
|
||||
result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Enter name:",
|
||||
Placeholder: "my-project",
|
||||
Default: "",
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string
|
||||
}
|
||||
```
|
||||
|
||||
### Overlay Dialogs
|
||||
|
||||
Modal dialogs with optional action buttons:
|
||||
|
||||
```go
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Confirmation",
|
||||
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
|
||||
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
|
||||
Width: 60, // 0 = 60% of terminal width
|
||||
MaxHeight: 20, // 0 = 80% of terminal height
|
||||
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
|
||||
Actions: []string{"Confirm", "Cancel"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Action string, result.Index int
|
||||
}
|
||||
```
|
||||
|
||||
### Editor Interceptor
|
||||
|
||||
Wrap the built-in text input with custom key handling and rendering:
|
||||
|
||||
```go
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
|
||||
if key == "ctrl+s" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
Render: func(width int, defaultContent string) string {
|
||||
return "[custom] " + defaultContent
|
||||
},
|
||||
})
|
||||
|
||||
ctx.ResetEditor() // remove interceptor
|
||||
ctx.SetEditorText("prefilled") // set editor text content
|
||||
```
|
||||
|
||||
**EditorKeyAction types:**
|
||||
- `ext.EditorKeyPassthrough` — let the default editor handle the key
|
||||
- `ext.EditorKeyConsumed` — swallow the key, do nothing
|
||||
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
|
||||
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
|
||||
|
||||
### UI Visibility
|
||||
|
||||
```go
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Session Data
|
||||
|
||||
```go
|
||||
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
|
||||
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
|
||||
path := ctx.GetSessionPath() // file path of session JSONL
|
||||
|
||||
// Persist custom data in the session tree:
|
||||
id, err := ctx.AppendEntry("my-type", "data string")
|
||||
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
|
||||
```
|
||||
|
||||
### Model Management
|
||||
|
||||
```go
|
||||
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
|
||||
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
|
||||
```
|
||||
|
||||
### Tool Management
|
||||
|
||||
```go
|
||||
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
|
||||
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
```
|
||||
|
||||
### LLM Completions
|
||||
|
||||
Make standalone LLM calls (bypasses the agent tool loop):
|
||||
|
||||
```go
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
Model: "", // empty = current model
|
||||
System: "You are ...", // optional system prompt
|
||||
Prompt: "Summarize...", // the prompt
|
||||
MaxTokens: 1000, // 0 = provider default
|
||||
OnChunk: func(chunk string) { /* streaming */ },
|
||||
})
|
||||
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
|
||||
```
|
||||
|
||||
### TUI Suspension
|
||||
|
||||
Temporarily release the terminal for interactive subprocesses:
|
||||
|
||||
```go
|
||||
ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("vim", "file.go")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
```
|
||||
|
||||
### Application Control
|
||||
|
||||
```go
|
||||
ctx.Exit() // graceful shutdown
|
||||
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
|
||||
```
|
||||
|
||||
### Context Fields
|
||||
|
||||
```go
|
||||
ctx.SessionID // string
|
||||
ctx.CWD // string — current working directory
|
||||
ctx.Model // string — active model name
|
||||
ctx.Interactive // bool — true if running in TUI mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Renderers
|
||||
|
||||
Customize how tool calls are displayed in the TUI:
|
||||
|
||||
```go
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "bash",
|
||||
DisplayName: "Shell", // replaces auto-capitalized name
|
||||
BorderColor: "#89b4fa",
|
||||
Background: "",
|
||||
BodyMarkdown: true, // render body through markdown
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(toolArgs), &args)
|
||||
return "$ " + args.Command
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "ERROR: " + toolResult
|
||||
}
|
||||
return toolResult
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Message Renderers
|
||||
|
||||
Define named output styles for `ctx.RenderMessage()`:
|
||||
|
||||
```go
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
return " " + content // green checkmark prefix
|
||||
},
|
||||
})
|
||||
|
||||
// Usage in handlers:
|
||||
ctx.RenderMessage("success", "All tests passed")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Yaegi Constraints
|
||||
|
||||
### No Named Function References in Struct Fields
|
||||
|
||||
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
|
||||
|
||||
```go
|
||||
// WRONG - will silently return zero values:
|
||||
func myHandler(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
|
||||
|
||||
// CORRECT - use anonymous closure:
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
|
||||
|
||||
### No Interfaces Across the Boundary
|
||||
|
||||
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
|
||||
|
||||
### Package-Level Variables for State
|
||||
|
||||
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
var callCount int
|
||||
var lastTool string
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
callCount++
|
||||
lastTool = e.ToolName
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Tool Call Blocking
|
||||
|
||||
Block dangerous operations by intercepting tool calls:
|
||||
|
||||
```go
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName == "bash" {
|
||||
var input struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(tc.Input), &input)
|
||||
if strings.Contains(input.Command, "rm -rf") {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Dangerous command blocked",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: System Prompt Injection
|
||||
|
||||
Augment the agent's behavior by injecting instructions:
|
||||
|
||||
```go
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
prompt := "Always respond with bullet points."
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Background Processing with SendMessage
|
||||
|
||||
Run work in a goroutine and inject results back:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command in the background",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
go func() {
|
||||
out, err := exec.Command("sh", "-c", args).CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
|
||||
return
|
||||
}
|
||||
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
|
||||
}()
|
||||
return "Running in background...", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Ephemeral Context Injection
|
||||
|
||||
Inject information into every LLM turn without persisting in session history:
|
||||
|
||||
```go
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
data, err := os.ReadFile(".kit/context.md")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1, // -1 = new message, not from session
|
||||
Role: "system",
|
||||
Content: string(data),
|
||||
}
|
||||
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Live Widget Updates
|
||||
|
||||
Update a widget periodically from a goroutine:
|
||||
|
||||
```go
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "clock",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
}
|
||||
}()
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Spawning Kit as a Sub-Agent
|
||||
|
||||
Extensions can spawn Kit as a subprocess for delegation:
|
||||
|
||||
```bash
|
||||
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
|
||||
```
|
||||
|
||||
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
|
||||
|
||||
---
|
||||
|
||||
## Testing Extensions
|
||||
|
||||
```bash
|
||||
# Validate syntax of all discovered extensions
|
||||
kit extensions validate
|
||||
|
||||
# List loaded extensions
|
||||
kit extensions list
|
||||
|
||||
# Run with a specific extension
|
||||
kit -e path/to/extension.go
|
||||
|
||||
# Run with multiple extensions
|
||||
kit -e ext1.go -e ext2.go
|
||||
|
||||
# Disable all extensions
|
||||
kit --no-extensions
|
||||
|
||||
# Generate an example extension scaffold
|
||||
kit extensions init
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example: Plan Mode
|
||||
|
||||
A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
var planActive bool
|
||||
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
if strings.ToLower(ctx.GetOption("plan")) == "true" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
|
||||
Focus on understanding, analysis, and generating plans.`
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, tools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(tools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil)
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files for Reference
|
||||
|
||||
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
|
||||
- [`internal/extensions/runner.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/runner.go) — Event dispatch and state management
|
||||
- [`internal/extensions/loader.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/loader.go) — Yaegi interpreter setup
|
||||
- [`internal/extensions/symbols.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/symbols.go) — All types exported to extensions
|
||||
- [`examples/extensions/`](https://github.com/mark3labs/kit/tree/main/examples/extensions) — 25+ working example extensions
|
||||
Reference in New Issue
Block a user