mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
49f8b485be
* feat(extensions): add OnLLMUsage, SetState, enriched AgentEndEvent (#53) Three additive primitives to the extension API: - OnLLMUsage event: per-LLM-call token + cost deltas attributed to the specific model/provider used for each round-trip. Derived from the SDK StepFinishEvent in the extension bridge. Enables accurate budget enforcement between calls instead of only at turn boundaries. - ctx.SetState / GetState / DeleteState / ListState: session-scoped, last-write-wins key-value store backed by a sidecar file (<session>.ext-state.json) outside the conversation tree. Reads are O(1), writes don't grow the JSONL, and the store is not duplicated on fork. State is preserved across hot-reloads. - Enriched AgentEndEvent: ToolCallCount, ToolNames, LLMCallCount, token deltas (input/output/cache-read/cache-write), CostDelta, and DurationMs populated by a per-turn aggregator. Existing handlers reading only Response/StopReason are unaffected. Includes unit tests for the state store, LLMUsage registration, enriched AgentEndEvent, turn aggregator, llmUsageMeta, and sidecar path derivation. Adds examples/extensions/usage-budget.go demoing all three primitives together. Documents the additions in README, the docs site (extensions overview, capabilities, examples), and the kit-extensions and kit-sdk skill guides. Fixes #53 * fix(extensions): address review feedback on state store and llmUsageMeta - Serialize SetState/DeleteState saver invocations through a new saverMu so overlapping atomic-rename writes can no longer race on the shared .tmp file and persist an older snapshot after a newer one. - LoadStateFromFile now clears the in-memory store when the sidecar is missing or empty, matching the documented "replace … with its contents" contract. This makes session-switching safe by preventing keys from a prior session leaking into a new one. Tests updated to cover both the missing-file and empty-file cases. - llmUsageMeta now detects Anthropic OAuth credentials and returns Cost=0, matching the comment and the existing usage_tracker behavior for OAuth users. Mirrors the OAuth detection already used in cmd/extension_context.go. - Document the single-in-flight-turn assumption baked into the per-turn aggregator with a clear migration path (per-turn ID) for if concurrent turns ever become a supported use case. * fix(extensions): release saverMu on panic in state store Extract a runSaver helper that locks saverMu and defers Unlock before invoking the persistence callback. Without the deferred Unlock, a panic inside the saver (e.g. disk full mid-write) would leave saverMu held forever and deadlock the next SetState/DeleteState. Both SetState and DeleteState now route through the helper. New TestRunner_State_Saver PanicReleasesSaverMu reproduces the deadlock window with a 2s deadline and proves the mutex is released after a panic.
200 lines
5.7 KiB
Go
200 lines
5.7 KiB
Go
package extensions
|
|
|
|
// NewTestAPI creates an API object wired for testing.
|
|
// This is used by the test harness to load extensions and verify behavior.
|
|
// The registration functions wire handlers directly to the provided extension.
|
|
func NewTestAPI(ext *LoadedExtension) API {
|
|
reg := func(event EventType, fn HandlerFunc) {
|
|
ext.Handlers[event] = append(ext.Handlers[event], fn)
|
|
}
|
|
|
|
return API{
|
|
onToolCall: func(h func(ToolCallEvent, Context) *ToolCallResult) {
|
|
reg(ToolCall, func(e Event, c Context) Result {
|
|
r := h(e.(ToolCallEvent), c)
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return *r
|
|
})
|
|
},
|
|
onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) {
|
|
reg(ToolExecutionStart, func(e Event, c Context) Result {
|
|
h(e.(ToolExecutionStartEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onToolExecEnd: func(h func(ToolExecutionEndEvent, Context)) {
|
|
reg(ToolExecutionEnd, func(e Event, c Context) Result {
|
|
h(e.(ToolExecutionEndEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onToolOutput: func(h func(ToolOutputEvent, Context)) {
|
|
reg(ToolOutput, func(e Event, c Context) Result {
|
|
h(e.(ToolOutputEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) {
|
|
reg(ToolResult, func(e Event, c Context) Result {
|
|
r := h(e.(ToolResultEvent), c)
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return *r
|
|
})
|
|
},
|
|
onInput: func(h func(InputEvent, Context) *InputResult) {
|
|
reg(Input, func(e Event, c Context) Result {
|
|
r := h(e.(InputEvent), c)
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return *r
|
|
})
|
|
},
|
|
onBeforeAgentStart: func(h func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult) {
|
|
reg(BeforeAgentStart, func(e Event, c Context) Result {
|
|
r := h(e.(BeforeAgentStartEvent), c)
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return *r
|
|
})
|
|
},
|
|
onAgentStart: func(h func(AgentStartEvent, Context)) {
|
|
reg(AgentStart, func(e Event, c Context) Result {
|
|
h(e.(AgentStartEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onAgentEnd: func(h func(AgentEndEvent, Context)) {
|
|
reg(AgentEnd, func(e Event, c Context) Result {
|
|
h(e.(AgentEndEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onMessageStart: func(h func(MessageStartEvent, Context)) {
|
|
reg(MessageStart, func(e Event, c Context) Result {
|
|
h(e.(MessageStartEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onMessageUpdate: func(h func(MessageUpdateEvent, Context)) {
|
|
reg(MessageUpdate, func(e Event, c Context) Result {
|
|
h(e.(MessageUpdateEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onMessageEnd: func(h func(MessageEndEvent, Context)) {
|
|
reg(MessageEnd, func(e Event, c Context) Result {
|
|
h(e.(MessageEndEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onSessionStart: func(h func(SessionStartEvent, Context)) {
|
|
reg(SessionStart, func(e Event, c Context) Result {
|
|
h(e.(SessionStartEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onSessionShutdown: func(h func(SessionShutdownEvent, Context)) {
|
|
reg(SessionShutdown, func(e Event, c Context) Result {
|
|
h(e.(SessionShutdownEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onModelChange: func(h func(ModelChangeEvent, Context)) {
|
|
reg(ModelChange, func(e Event, c Context) Result {
|
|
h(e.(ModelChangeEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
|
|
reg(ContextPrepare, func(e Event, c Context) Result {
|
|
r := h(e.(ContextPrepareEvent), c)
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return *r
|
|
})
|
|
},
|
|
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
|
|
reg(BeforeFork, func(e Event, c Context) Result {
|
|
r := h(e.(BeforeForkEvent), c)
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return *r
|
|
})
|
|
},
|
|
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
|
|
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
|
|
r := h(e.(BeforeSessionSwitchEvent), c)
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return *r
|
|
})
|
|
},
|
|
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
|
|
reg(BeforeCompact, func(e Event, c Context) Result {
|
|
r := h(e.(BeforeCompactEvent), c)
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return *r
|
|
})
|
|
},
|
|
registerToolFn: func(tool ToolDef) {
|
|
ext.Tools = append(ext.Tools, tool)
|
|
},
|
|
registerCmdFn: func(cmd CommandDef) {
|
|
ext.Commands = append(ext.Commands, cmd)
|
|
},
|
|
registerToolRendererFn: func(config ToolRenderConfig) {
|
|
ext.ToolRenderers = append(ext.ToolRenderers, config)
|
|
},
|
|
onCustomEvent: func(name string, handler func(string)) {
|
|
if ext.CustomEventHandlers == nil {
|
|
ext.CustomEventHandlers = make(map[string][]func(string))
|
|
}
|
|
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
|
|
},
|
|
registerOption: func(opt OptionDef) {
|
|
ext.Options = append(ext.Options, opt)
|
|
},
|
|
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
|
|
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
|
|
},
|
|
registerMessageRendererFn: func(config MessageRendererConfig) {
|
|
ext.MessageRenderers = append(ext.MessageRenderers, config)
|
|
},
|
|
onSubagentStart: func(h func(SubagentStartEvent, Context)) {
|
|
reg(SubagentStart, func(e Event, c Context) Result {
|
|
h(e.(SubagentStartEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onSubagentChunk: func(h func(SubagentChunkEvent, Context)) {
|
|
reg(SubagentChunk, func(e Event, c Context) Result {
|
|
h(e.(SubagentChunkEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onSubagentEnd: func(h func(SubagentEndEvent, Context)) {
|
|
reg(SubagentEnd, func(e Event, c Context) Result {
|
|
h(e.(SubagentEndEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
onLLMUsage: func(h func(LLMUsageEvent, Context)) {
|
|
reg(LLMUsage, func(e Event, c Context) Result {
|
|
h(e.(LLMUsageEvent), c)
|
|
return nil
|
|
})
|
|
},
|
|
}
|
|
}
|