diff --git a/docs/extension-api.md b/docs/extension-api.md index ac0a7fd7..5aca340a 100644 --- a/docs/extension-api.md +++ b/docs/extension-api.md @@ -1,5 +1,25 @@ # Extension Lua API + +## Agent lifecycle seams (planned) + +The extension host is moving toward explicit lifecycle/tool/context seams while keeping the default UI and assistant loop Go-owned. New APIs should be runtime-neutral first and then exposed through Lua. + +Planned lifecycle events include: + +| Event | Purpose | Mutation policy | +| --- | --- | --- | +| `session_start` / `session_load` / `session_shutdown` | Observe session lifecycle. | Observational initially. | +| `input` / `prompt_prepare` | Inspect or eventually transform user input before a turn. | Explicit transforms only. | +| `turn_start` / `turn_end` / `agent_end` | Observe one assistant turn. | Observational initially. | +| `context_build` | Add bounded, labeled context blocks and inspect context budgets. | Bounded contributions only. | +| `before_provider_request` / `after_provider_response` / `provider_error` | Observe provider traffic with redacted payloads. | Conservative typed mutation only. | +| `tool_call` / `tool_result` / `tool_error` | Mediate tool execution and results. | Allow, reject, modify, synthesize, or redact per documented contract. | +| `message_append` | Observe durable message writes. | Observational initially. | + +All lifecycle payloads must be bounded. Provider events must redact auth headers and secrets. Tool middleware decisions should be visible in diagnostics. + + ## Status This document describes the currently implemented Lua API surface. diff --git a/docs/extension-roadmap.md b/docs/extension-roadmap.md index 3a88f324..b7525463 100644 --- a/docs/extension-roadmap.md +++ b/docs/extension-roadmap.md @@ -189,7 +189,25 @@ Keep raw draw operations available. Higher-level rendering should be Lua-composa Goal: add structured agent lifecycle events without making the default UI extension-owned. -Add structured lifecycle events: +Implementation should happen as a stack of small PRs. Each PR must define runtime-neutral Go contracts first, expose them through Lua second, and keep default behavior unchanged when no extension handles the event. See the kanban in `TODO.md` for executable tasks. + +### Phase 5.1: lifecycle event contracts + +Add typed event names, payloads, results, and dispatch diagnostics for the agent loop. This phase should not change assistant behavior. It gives later phases a stable host contract. + +### Phase 5.2: session, input, and turn events + +Emit bounded events around session load/start/shutdown, user input, prompt preparation, turn start/end, message append, and agent end. These events are observational unless a later PR explicitly documents mutation. + +### Phase 5.3: context build seams + +Add a typed `context_build` event for bounded context contributions and context accounting. Extensions may add labeled context blocks within a budget. The runtime should expose system/history/skills/extensions token estimates for debugging. + +### Phase 5.4: provider lifecycle hooks + +Emit `before_provider_request`, `after_provider_response`, and `provider_error` with redacted, bounded payloads. Request mutation must be conservative, typed, and logged. Auth headers and secrets must never be exposed. + +Initial lifecycle events: - `session_start` - `session_load` @@ -201,7 +219,7 @@ Add structured lifecycle events: - `before_agent_start` - `agent_start` - `turn_start` -- `context` +- `context_build` - `before_provider_request` - `after_provider_response` - `provider_error` @@ -213,13 +231,15 @@ Add structured lifecycle events: - `agent_end` - `shutdown` -Events should have structured payloads, bounded data, clear mutation contracts, and extension timing diagnostics. +Events should have structured payloads, bounded data, clear mutation contracts, extension timing diagnostics, and tests for both default behavior and extension-modified behavior. ## Phase 6: tool middleware and extension tools Goal: let extensions and hooks influence tool execution through explicit middleware contracts. -Tool middleware should support: +### Phase 6.1: built-in tool middleware + +Run tool middleware around built-in tool execution first. Middleware should support: - observe and continue - reject with a synthetic result @@ -227,6 +247,10 @@ Tool middleware should support: - synthesize a result without executing the tool - redact or rewrite tool output before the model sees it +### Phase 6.2: unified tool registry + +Replace ad-hoc built-in registry creation in the assistant loop with one model-visible registry that can hold built-ins, extension tools, future MCP tools, toolboxes, skill-bundled tools, subagents, and LSP tools. + Extension-registered tools should be wired into the same model-visible tool registry as built-ins. Skill-bundled tools should activate only when the skill activates. ## Phase 7: hooks, skills, and subagents diff --git a/docs/runtime-architecture.md b/docs/runtime-architecture.md index baddd593..a5366a9d 100644 --- a/docs/runtime-architecture.md +++ b/docs/runtime-architecture.md @@ -176,6 +176,31 @@ The most valuable ideas to adopt are: The lesson is not to move default UI into extensions. The lesson is to make agent behavior and workflow policy extensible while Go keeps the default UI fast and coherent. +## Agent lifecycle seams roadmap + +The next major architecture pillar is explicit lifecycle/tool/context seams. This work should be delivered as stacked, reviewable PRs: + +1. lifecycle event contracts and dispatch +2. session/input/turn lifecycle instrumentation +3. context build hooks, bounded context contributions, and token breakdown +4. provider request/response/error hooks +5. built-in tool middleware +6. unified tool registry for built-ins and extension tools +7. diagnostics, examples, and hardening + +The default terminal UI and assistant behavior must remain Go-owned and stable throughout this stack. Extension runtimes should observe and customize through typed host contracts rather than taking over hot UI paths. + +Design constraints for these seams: + +- payloads are bounded and redacted by default +- mutation contracts are explicit per event +- extension errors are visible and cannot corrupt the agent loop +- tool decisions are auditable +- context contributions have labels, token estimates, and budgets +- provider hooks never expose auth headers or secrets +- tests cover the no-extension path and at least one extension-modified path + + ## What still does not work The remaining architectural gaps are mostly about ownership: diff --git a/internal/extension/lifecycle.go b/internal/extension/lifecycle.go new file mode 100644 index 00000000..84e189b7 --- /dev/null +++ b/internal/extension/lifecycle.go @@ -0,0 +1,155 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "time" + + lua "github.com/yuin/gopher-lua" +) + +// LifecycleEventName identifies one agent/runtime lifecycle event. +type LifecycleEventName string + +const ( + // LifecycleSessionStart fires when a new session is created. + LifecycleSessionStart LifecycleEventName = "session_start" + // LifecycleSessionLoad fires when an existing session is loaded. + LifecycleSessionLoad LifecycleEventName = "session_load" + // LifecycleSessionSave fires after durable session state is written. + LifecycleSessionSave LifecycleEventName = "session_save" + // LifecycleSessionShutdown fires before a session/runtime shuts down. + LifecycleSessionShutdown LifecycleEventName = "session_shutdown" + // LifecycleResourcesDiscover fires while project resources are discovered. + LifecycleResourcesDiscover LifecycleEventName = "resources_discover" + // LifecycleInput fires when raw user input enters the assistant runtime. + LifecycleInput LifecycleEventName = "input" + // LifecyclePromptPrepare fires before a prompt becomes a model turn. + LifecyclePromptPrepare LifecycleEventName = "prompt_prepare" + // LifecycleBeforeAgentStart fires before assistant turn execution starts. + LifecycleBeforeAgentStart LifecycleEventName = "before_agent_start" + // LifecycleAgentStart fires when assistant turn execution starts. + LifecycleAgentStart LifecycleEventName = "agent_start" + // LifecycleTurnStart fires when a model-facing turn starts. + LifecycleTurnStart LifecycleEventName = "turn_start" + // LifecycleContextBuild fires while model context is assembled. + LifecycleContextBuild LifecycleEventName = "context_build" + // LifecycleBeforeProviderRequest fires before a provider request is sent. + LifecycleBeforeProviderRequest LifecycleEventName = "before_provider_request" + // LifecycleAfterProviderResponse fires after a provider response is received. + LifecycleAfterProviderResponse LifecycleEventName = "after_provider_response" + // LifecycleProviderError fires when a provider request fails. + LifecycleProviderError LifecycleEventName = "provider_error" + // LifecycleToolCall fires before a tool call executes. + LifecycleToolCall LifecycleEventName = "tool_call" + // LifecycleToolResult fires after a tool call returns. + LifecycleToolResult LifecycleEventName = "tool_result" + // LifecycleToolError fires when a tool call fails. + LifecycleToolError LifecycleEventName = "tool_error" + // LifecycleMessageAppend fires after a durable message is appended. + LifecycleMessageAppend LifecycleEventName = "message_append" + // LifecycleTurnEnd fires when a model-facing turn ends. + LifecycleTurnEnd LifecycleEventName = "turn_end" + // LifecycleAgentEnd fires when assistant turn execution ends. + LifecycleAgentEnd LifecycleEventName = "agent_end" + // LifecycleShutdown fires before the runtime exits. + LifecycleShutdown LifecycleEventName = "shutdown" +) + +// LifecycleEvent is the runtime-neutral payload passed through lifecycle handlers. +type LifecycleEvent struct { + Payload map[string]any `json:"payload"` + Name LifecycleEventName `json:"name"` +} + +// LifecycleDispatchResult describes the outcome of lifecycle handler dispatch. +type LifecycleDispatchResult struct { + Payload map[string]any `json:"payload"` + Name string `json:"name"` + Errors []string `json:"errors"` + Duration time.Duration `json:"duration"` + HandlerCount int `json:"handler_count"` + Consumed bool `json:"consumed"` + Stopped bool `json:"stopped"` +} + +// LifecycleDispatcher emits runtime-neutral lifecycle events. +type LifecycleDispatcher interface { + DispatchLifecycle(ctx context.Context, event LifecycleEvent) (LifecycleDispatchResult, error) +} + +// DispatchLifecycle runs registered handlers for a lifecycle event. +func (manager *Manager) DispatchLifecycle(ctx context.Context, event LifecycleEvent) (LifecycleDispatchResult, error) { + if event.Name == "" { + return LifecycleDispatchResult{}, fmt.Errorf("extension: lifecycle event name is required") + } + + result := LifecycleDispatchResult{ + Name: string(event.Name), + Payload: cloneMap(event.Payload), + Errors: []string{}, + HandlerCount: 0, + Duration: 0, + Consumed: false, + Stopped: false, + } + startedAt := time.Now() + + for _, handler := range manager.handlersFor(string(event.Name)) { + if err := ctx.Err(); err != nil { + result.Duration = time.Since(startedAt) + return result, err + } + + result.HandlerCount++ + luaResult, err := callLuaPrepared( + handler.extension, + nil, + handler.function, + func(state *lua.LState) []lua.LValue { + return []lua.LValue{lifecycleEventTable(state, event.Name, result.Payload)} + }, + ) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + continue + } + applyLifecycleLuaResult(&result, luaResult) + if result.Stopped { + break + } + } + + result.Duration = time.Since(startedAt) + if len(result.Errors) > 0 { + return result, errors.New("one or more lifecycle handlers failed") + } + + return result, nil +} + +func lifecycleEventTable(state *lua.LState, name LifecycleEventName, payload map[string]any) *lua.LTable { + return mapToLuaTable(state, map[string]any{ + "name": string(name), + "payload": payload, + }) +} + +func applyLifecycleLuaResult(result *LifecycleDispatchResult, value lua.LValue) { + table, ok := value.(*lua.LTable) + if !ok { + return + } + if luaTableBool(table, "handled") || luaTableBool(table, "consumed") { + result.Consumed = true + } + if luaTableBool(table, "stop") || luaTableBool(table, "stopped") { + result.Consumed = true + result.Stopped = true + } + payloadValue := table.RawGetString("payload") + if payload, ok := luaValueToGo(payloadValue).(map[string]any); ok { + result.Payload = payload + } +} diff --git a/internal/extension/lifecycle_test.go b/internal/extension/lifecycle_test.go new file mode 100644 index 00000000..8b1a7f1e --- /dev/null +++ b/internal/extension/lifecycle_test.go @@ -0,0 +1,100 @@ +package extension_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/omarluq/librecode/internal/extension" +) + +func TestManager_DispatchLifecycleRunsHandlersInPriorityOrder(t *testing.T) { + t.Parallel() + + manager := loadTestExtension(t, ` +local lc = require("librecode") +lc.on("turn_start", { priority = 1 }, function(event) + event.payload.order = event.payload.order .. "b" + return { payload = event.payload } +end) +lc.on("turn_start", { priority = 10 }, function(event) + event.payload.order = event.payload.order .. "a" + return { payload = event.payload } +end) +`) + result, err := manager.DispatchLifecycle(context.Background(), extension.LifecycleEvent{ + Name: extension.LifecycleTurnStart, + Payload: map[string]any{"order": ""}, + }) + + require.NoError(t, err) + assert.Equal(t, "turn_start", result.Name) + assert.Equal(t, "ab", result.Payload["order"]) + assert.Equal(t, 2, result.HandlerCount) + assert.Empty(t, result.Errors) + assert.False(t, result.Consumed) + assert.False(t, result.Stopped) + assert.Positive(t, result.Duration) +} + +func TestManager_DispatchLifecycleCanStopHandlers(t *testing.T) { + t.Parallel() + + manager := loadTestExtension(t, ` +local lc = require("librecode") +lc.on("context_build", { priority = 10 }, function(event) + event.payload.count = event.payload.count + 1 + return { payload = event.payload, stop = true } +end) +lc.on("context_build", { priority = 1 }, function(event) + event.payload.count = event.payload.count + 100 + return { payload = event.payload } +end) +`) + result, err := manager.DispatchLifecycle(context.Background(), extension.LifecycleEvent{ + Name: extension.LifecycleContextBuild, + Payload: map[string]any{"count": 0}, + }) + + require.NoError(t, err) + assert.Equal(t, 1.0, result.Payload["count"]) + assert.Equal(t, 1, result.HandlerCount) + assert.True(t, result.Consumed) + assert.True(t, result.Stopped) +} + +func TestManager_DispatchLifecycleCollectsHandlerErrors(t *testing.T) { + t.Parallel() + + manager := loadTestExtension(t, ` +local lc = require("librecode") +lc.on("provider_error", function() + error("boom") +end) +`) + result, err := manager.DispatchLifecycle(context.Background(), extension.LifecycleEvent{ + Name: extension.LifecycleProviderError, + Payload: map[string]any{"provider": "test"}, + }) + + require.Error(t, err) + assert.Equal(t, 1, result.HandlerCount) + require.Len(t, result.Errors, 1) + assert.Contains(t, result.Errors[0], "boom") +} + +func TestManager_DispatchLifecycleRequiresName(t *testing.T) { + t.Parallel() + + manager := extension.NewManager(nil) + t.Cleanup(manager.Shutdown) + + _, err := manager.DispatchLifecycle(context.Background(), extension.LifecycleEvent{ + Name: "", + Payload: map[string]any{}, + }) + + require.Error(t, err) +}