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
40 changes: 40 additions & 0 deletions docs/extension-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ Planned lifecycle events include:

All lifecycle payloads must be bounded. Provider events must redact auth headers and secrets. Tool middleware decisions should be visible in diagnostics.

librecode separates two event mechanisms:

- the **ro-backed event stream** for observational async/fanout events such as lifecycle telemetry, headless JSON output, timers, retry notifications, and future watchers
- the **ordered middleware dispatcher** for hooks that return decisions or mutations, such as tool allow/reject, context contributions, and provider request changes

Extensions may observe lifecycle events through Lua handlers, while core Go services can subscribe to the reactive stream for telemetry and integrations. Do not use the observational stream for state mutations that require deterministic ordering.


## Status

Expand Down Expand Up @@ -689,6 +696,39 @@ Useful patterns include:
- label updates
- low-level buffer mutation

## Assistant lifecycle events

The extension host also emits bounded lifecycle events around the assistant runtime. These are runtime-neutral host events exposed through Lua as regular `lc.on(...)` handlers. Handlers may observe payloads and return `{ stop = true }` to prevent later handlers from running, but session/input/turn events are observational in the current release.

Current assistant lifecycle events include:

- `input`
- `prompt_prepare`
- `session_start`
- `session_load`
- `before_agent_start`
- `agent_start`
- `turn_start`
- `message_append`
- `turn_end`
- `agent_end`

Example:

```lua
local lc = require("librecode")

lc.on("turn_start", function(event)
lc.log("turn started for session " .. event.payload.session_id)
end)

lc.on("message_append", function(event)
lc.log("appended " .. event.payload.role .. " entry " .. event.payload.entry_id)
end)
```

Lifecycle payloads are intentionally bounded. `message_append` includes the appended entry text and metadata for the current message, but lifecycle events do not include full transcript history. Future PRs will add explicit mutation contracts for context, provider, and tool middleware.

## Current limitations

The API is still incomplete compared with the long-term target.
Expand Down
30 changes: 28 additions & 2 deletions docs/extension-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,41 @@ 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.

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.
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. The executable kanban lives under `.librecode/work/plans/` so stable docs do not become scratch state.

### Event stream vs middleware dispatcher

librecode uses two different mechanisms on purpose:

1. **Reactive event stream** — ro-backed, observational, async/fanout.
Use this for lifecycle telemetry, logs, UI notifications, headless JSON streams, retry/timer streams, future file watchers, and other data that flows over time without needing an ordered return value.
2. **Middleware dispatcher** — ordered, synchronous, return-value driven.
Use this for tool allow/reject/modify decisions, context contributions, provider request mutation, and any hook that must deterministically change runtime behavior.

Rule of thumb: if it is async, streaming, cancellable, or fanout, build it on the ro event spine. If it returns a decision or mutation, keep it in the explicit middleware dispatcher and emit observational ro events before/after the decision.

### 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.
Status: implemented for prompt-time input/session/turn events.

Implemented events:

- `input`
- `prompt_prepare`
- `session_start`
- `session_load`
- `before_agent_start`
- `agent_start`
- `turn_start`
- `message_append`
- `turn_end`
- `agent_end`

The events are bounded and observational. They do not include full transcript history and do not currently mutate prompt text or session state.

### Phase 5.3: context build seams

Expand Down
4 changes: 4 additions & 0 deletions internal/assistant/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const (
jsonOutputKey = "output"
jsonOutputTokensKey = "output_tokens"
jsonToolChoiceKey = "tool_choice"
jsonInputTokensKey = "input_tokens"
jsonContextTokensKey = "context_tokens"
jsonContextWindowKey = "context_window"
jsonSessionIDKey = "session_id"
jsonTextKey = "text"
jsonThinkingKey = "thinking"
jsonDisplayKey = "display"
Expand Down
252 changes: 252 additions & 0 deletions internal/assistant/lifecycle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
package assistant

import (
"context"
"log/slog"

"github.com/omarluq/librecode/internal/database"
"github.com/omarluq/librecode/internal/extension"
"github.com/omarluq/librecode/internal/model"
)

type promptTurnLifecycle struct {
runtime *Runtime
sessionID string
userEntryID string
ended bool
}

func newPromptTurnLifecycle(
runtime *Runtime,
sessionID string,
userEntryID string,
) *promptTurnLifecycle {
return &promptTurnLifecycle{
runtime: runtime,
sessionID: sessionID,
userEntryID: userEntryID,
ended: false,
}
}

func (lifecycle *promptTurnLifecycle) dispatchEnd(
ctx context.Context,
assistantEntryID string,
cached bool,
usage model.TokenUsage,
) {
if lifecycle == nil || lifecycle.ended {
return
}
lifecycle.ended = true
lifecycle.runtime.dispatchTurnEndLifecycle(
ctx,
lifecycle.sessionID,
lifecycle.userEntryID,
assistantEntryID,
cached,
usage,
)
}

func (lifecycle *promptTurnLifecycle) dispatchError(ctx context.Context, err error) {
if lifecycle == nil || lifecycle.ended || err == nil {
return
}
lifecycle.ended = true
lifecycle.runtime.dispatchTurnErrorLifecycle(ctx, lifecycle.sessionID, lifecycle.userEntryID, err)
}

func (runtime *Runtime) dispatchLifecycle(
ctx context.Context,
name extension.LifecycleEventName,
payload map[string]any,
) {
runtime.emit(ctx, string(name), payload)
if runtime.extensions == nil {
return
}
_, err := runtime.extensions.DispatchLifecycle(ctx, extension.LifecycleEvent{
Payload: payload,
Name: name,
})
if err != nil && runtime.logger != nil {
runtime.logger.Debug(
"extension lifecycle event failed",
slog.String("event", string(name)),
slog.Any("error", err),
)
}
}

func (runtime *Runtime) dispatchMessageAppend(ctx context.Context, entry *database.EntryEntity) {
if entry == nil {
return
}
runtime.dispatchLifecycle(ctx, extension.LifecycleMessageAppend, entryLifecyclePayload(entry))
}

func (runtime *Runtime) dispatchTurnStartLifecycle(
ctx context.Context,
sessionID string,
request *PromptRequest,
userEntryID string,
parentEntryID *string,
) {
payload := turnLifecyclePayload(sessionID, request.CWD, request.Text, userEntryID, parentEntryID)
runtime.dispatchLifecycle(ctx, extension.LifecycleBeforeAgentStart, payload)
runtime.dispatchLifecycle(ctx, extension.LifecycleAgentStart, payload)
runtime.dispatchLifecycle(ctx, extension.LifecycleTurnStart, payload)
}

func (runtime *Runtime) dispatchTurnEndLifecycle(
ctx context.Context,
sessionID string,
userEntryID string,
assistantEntryID string,
cached bool,
usage model.TokenUsage,
) {
payload := turnEndLifecyclePayload(sessionID, userEntryID, assistantEntryID, cached, nil, usage)
runtime.dispatchLifecycle(ctx, extension.LifecycleTurnEnd, payload)
runtime.dispatchLifecycle(ctx, extension.LifecycleAgentEnd, payload)
}

func (runtime *Runtime) dispatchTurnErrorLifecycle(
ctx context.Context,
sessionID string,
userEntryID string,
turnErr error,
) {
if turnErr == nil {
return
}
payload := turnEndLifecyclePayload(sessionID, userEntryID, "", false, turnErr, model.EmptyTokenUsage())
runtime.dispatchLifecycle(ctx, extension.LifecycleTurnEnd, payload)
runtime.dispatchLifecycle(ctx, extension.LifecycleAgentEnd, payload)
}

func promptLifecyclePayload(request *PromptRequest) map[string]any {
if request == nil {
return map[string]any{}
}

return map[string]any{
lifecycleCWDKey: request.CWD,
jsonToolNameKey: request.Name,
lifecycleParentEntryIDKey: stringPtrValue(request.ParentEntryID),
lifecyclePromptKey: request.Text,
"resume_latest": request.ResumeLatest,
jsonSessionIDKey: request.SessionID,
}
}

func sessionLifecyclePayload(session *database.SessionEntity) map[string]any {
if session == nil {
return map[string]any{}
}

return map[string]any{
lifecycleCWDKey: session.CWD,
lifecycleCreatedAtKey: session.CreatedAt.Format(timeFormatRFC3339Nano),
jsonToolNameKey: session.Name,
lifecycleParentSessionKey: session.ParentSession,
jsonSessionIDKey: session.ID,
lifecycleUpdatedAtKey: session.UpdatedAt.Format(timeFormatRFC3339Nano),
}
}

func turnLifecyclePayload(
sessionID string,
cwd string,
prompt string,
userEntryID string,
parentEntryID *string,
) map[string]any {
return map[string]any{
lifecycleCWDKey: cwd,
lifecycleParentEntryIDKey: stringPtrValue(parentEntryID),
lifecyclePromptKey: prompt,
jsonSessionIDKey: sessionID,
lifecycleUserEntryIDKey: userEntryID,
}
}

func turnEndLifecyclePayload(
sessionID string,
userEntryID string,
assistantEntryID string,
cached bool,
turnErr error,
usage model.TokenUsage,
) map[string]any {
payload := map[string]any{
lifecycleAssistantEntryIDKey: assistantEntryID,
"cached": cached,
lifecycleErrorKey: "",
jsonSessionIDKey: sessionID,
"usage": tokenUsageLifecyclePayload(usage),
lifecycleUserEntryIDKey: userEntryID,
}
if turnErr != nil {
payload[lifecycleErrorKey] = turnErr.Error()
}

return payload
}

func entryLifecyclePayload(entry *database.EntryEntity) map[string]any {
return map[string]any{
lifecycleCreatedAtKey: entry.CreatedAt.Format(timeFormatRFC3339Nano),
"custom_type": entry.CustomType,
"display": entry.Display,
lifecycleEntryIDKey: entry.ID,
"entry_type": string(entry.Type),
jsonModelKey: entry.Message.Model,
"model_facing": entry.ModelFacing,
"parent_id": stringPtrValue(entry.ParentID),
"provider": entry.Message.Provider,
jsonRoleKey: string(entry.Message.Role),
jsonSessionIDKey: entry.SessionID,
jsonSummaryKey: entry.Summary,
jsonTextKey: entry.Message.Content,
"token_estimate": entry.TokenEstimate,
"tool_args_json": entry.ToolArgsJSON,
"tool_name": entry.ToolName,
"tool_status": entry.ToolStatus,
"branch_from_entry_id": entry.BranchFromEntryID,
"compaction_first_kept_entry_id": entry.CompactionFirstKeptEntryID,
"compaction_tokens_before": entry.CompactionTokensBefore,
}
}

func tokenUsageLifecyclePayload(usage model.TokenUsage) map[string]any {
return map[string]any{
jsonContextTokensKey: usage.ContextTokens,
jsonContextWindowKey: usage.ContextWindow,
jsonInputTokensKey: usage.InputTokens,
jsonOutputTokensKey: usage.OutputTokens,
}
}

func stringPtrValue(value *string) string {
if value == nil {
return ""
}

return *value
}

const (
lifecycleAssistantEntryIDKey = "assistant_entry_id"
lifecycleCWDKey = "cwd"
lifecycleCreatedAtKey = "created_at"
lifecycleEntryIDKey = "entry_id"
lifecycleErrorKey = "error"
lifecycleParentEntryIDKey = "parent_entry_id"
lifecycleParentSessionKey = "parent_session"
lifecyclePromptKey = "prompt"
lifecycleUpdatedAtKey = "updated_at"
lifecycleUserEntryIDKey = "user_entry_id"
timeFormatRFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00"
)
Loading
Loading