Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/extension-api.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
32 changes: 28 additions & 4 deletions docs/extension-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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`
Expand All @@ -213,20 +231,26 @@ 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
- modify tool input
- 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
Expand Down
25 changes: 25 additions & 0 deletions docs/runtime-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
155 changes: 155 additions & 0 deletions internal/extension/lifecycle.go
Original file line number Diff line number Diff line change
@@ -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
}
}
100 changes: 100 additions & 0 deletions internal/extension/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading