# Agent Session Shell Specification ## Purpose Defines the client-side orchestration boundary between the UI layer, the API proxy, Zustand stores, and the tool registry. The route delegates to a config-selected provider adapter for real LLM execution, and the orchestrator drives a multi-turn tool-call loop with iteration guard and argument validation. ## Requirements ### Requirement: Chat Store The system SHALL maintain a `chatStore` (Zustand) with state: `messages: ChatMessage[]`, `loading: boolean`, `error: string | null`. The store MUST provide `addMessage(msg)`, `setLoading(v)`, `setError(e)`, and `clearMessages()` actions. The store MUST follow the existing `create()(persist(...))` pattern used by other stores in the project. #### Scenario: Message lifecycle - GIVEN `chatStore` is initialized with empty messages - WHEN `addMessage({ role: "user", content: "Hello" })` is called - THEN `chatStore.messages` contains one entry with the correct role and content #### Scenario: Error state persists until cleared - GIVEN `setError("API failed")` was called - WHEN the UI reads `chatStore.error` - THEN it returns `"API failed"` - AND `chatStore.loading` is `false` ### Requirement: Agent Store The system SHALL maintain an `agentStore` (Zustand) with state: `status: ExecutionStatus`, `activeTool: string | null`, `context: AgentContext | null`. The store MUST provide `setStatus`, `setActiveTool`, `setContext`, and `reset` actions. #### Scenario: Tool execution tracking - GIVEN the orchestrator begins executing tool `echo_context` - WHEN `setActiveTool("echo_context")` and `setStatus("processing")` are called - THEN `agentStore.activeTool` is `"echo_context"` and `agentStore.status` is `"processing"` #### Scenario: Reset after completion - GIVEN `agentStore` has `status: "processing"` and `activeTool: "echo_context"` - WHEN `reset()` is called - THEN `status` is `"idle"` and `activeTool` is `null` ### Requirement: API Proxy Route The system SHALL expose `POST /api/agent/chat` that accepts `{ messages: ChatMessage[], context?: AgentContext }` and returns `{ content: string, toolCalls?: ToolCall[] }`. The route MUST resolve the provider adapter from configuration, assemble a system prompt via `buildSystemPrompt(context)`, and pass internal tool schemas from the registry to the adapter. The adapter handles all provider-specific wire-format conversion. The request body MUST be validated via zod; invalid input returns 400. Provider errors return 502. #### Scenario: Valid request returns provider response - GIVEN provider config is set and a POST with valid `{ messages, context }` arrives - WHEN the handler calls through the provider adapter - THEN it returns 200 with `{ content: "...", toolCalls?: [...] }` #### Scenario: Invalid request returns 400 - GIVEN a POST request with `{}` (missing `messages`) - WHEN the handler processes the request - THEN it returns 400 with an error description #### Scenario: Provider error returns 502 - GIVEN the provider adapter call throws a network or auth error - WHEN the handler catches it - THEN it returns 502 with `{ error: "LLM provider error" }` ### Requirement: Orchestrator Shell The system SHALL provide a client-side `orchestrator.run(messages, context)` function that iterates: (1) sets `agentStore.status` to `"sending"`, (2) POSTs `{ messages, context }` to `/api/agent/chat`, (3) if the response contains `toolCalls`, validates args, resolves each via the tool registry (if a single tool throws, the error MUST be captured in a `ToolResult` and the loop MUST continue so the LLM can recover), appends tool results to the message history, and loops back to step 2 with the updated history, (4) when the response has no `toolCalls`, appends the final assistant message to `chatStore`, sets status to `"idle"`. The loop MUST NOT exceed the max iteration guard. On API or network errors (not per-tool errors), the orchestrator MUST set `chatStore.error` and `agentStore.status` to `"error"`. #### Scenario: Happy path with no tool calls - GIVEN `chatStore` has one user message and the API returns `{ content: "Hello!", toolCalls: [] }` - WHEN `orchestrator.run()` is invoked - THEN `chatStore.messages` contains the assistant reply "Hello!" - AND `agentStore.status` is `"idle"` #### Scenario: Tool call then final answer - GIVEN iteration 1 returns `{ content: "", toolCalls: [{ name: "transcribe_video", args: {} }] }` - AND the tool executes successfully - AND iteration 2 returns `{ content: "Here is the transcription...", toolCalls: [] }` - WHEN the orchestrator loop completes - THEN `chatStore.messages` contains the tool result AND the final assistant message - AND `agentStore.status` is `"idle"` #### Scenario: Error during tool execution — per-tool recovery - GIVEN iteration 1 returns `{ content: "", toolCalls: [{ name: "transcribe_video", args: {} }] }` and the tool throws - WHEN the orchestrator catches the error inside `resolveToolCalls` - THEN a `ToolResult` with the error description is appended to the message history - AND the loop continues, sending tool-result back to the LLM for a final answer ### Requirement: Tool Registry Shell The system SHALL provide a `ToolRegistry` extending `DefinitionRegistry` where `ToolDefinition` contains `{ name, description, parameters, execute }`. The registry MUST support `register`, `get`, `has`, `getAll`, and a `toToolSchemas()` method that exports every registered tool's parameters in a provider-agnostic internal schema format. The provider adapter is responsible for converting these internal schemas to its specific wire format. In this phase `echo_context` MUST NOT be registered; exactly one tool — `transcribe_video` — MUST be registered. #### Scenario: Schema export produces internal format - GIVEN `transcribe_video` is registered with parameters `[{ key: "assetId", type: "string", required: false }, ...]` - WHEN `toToolSchemas()` is called - THEN it returns an array with one entry containing `{ name: "transcribe_video", description, parameters: [...] }` in the internal format ### Requirement: Shared Foundational Types The system SHALL define types in `apps/web/src/agent/types.ts`: `ChatMessage { id, role, content, toolCalls?, timestamp }`, `ToolCall { id, name, args }`, `ToolResult { toolCallId, name, result, error? }`, `ExecutionState = "idle" | "sending" | "processing" | "responding" | "error"`, `AgentContext { projectId, activeSceneId, mediaAssets, playbackTimeMs }`, `ToolDefinition { name, description, parameters, execute }`. These types MUST be the single source of truth shared across stores, orchestrator, registry, and API route. #### Scenario: Types are importable everywhere - GIVEN `types.ts` exports `ChatMessage`, `ToolCall`, `ToolResult`, `ExecutionState`, `AgentContext`, `ToolDefinition` - WHEN any module in `agent/`, `stores/`, or `app/api/agent/` imports from it - THEN TypeScript compiles without errors ### Requirement: Provider Adapter Interface The system SHALL define a server-side provider adapter interface with a single method `chat(params: ProviderChatParams): Promise`. `ProviderChatParams` MUST include `messages`, `systemPrompt`, and `toolSchemas` in a provider-agnostic internal format. `ProviderChatResponse` MUST include `content: string` and `toolCalls?: ProviderToolCall[]` where `ProviderToolCall { id, name, args }`. Each concrete adapter converts internal tool schemas to its own wire format. The factory MUST support at least `openai-compatible` and `gemini` providers. Adapters whose native API does not assign IDs to tool calls MUST synthesize unique IDs so every returned `ToolCall.id` is a non-empty string. #### Scenario: Adapter translates tool schemas to wire format - GIVEN `transcribe_video` has internal parameters `[{ key: "assetId", type: "string", required: false }]` - WHEN the adapter receives tool schemas before calling the provider - THEN it converts them to its provider-specific wire format #### Scenario: Adapter selected by config — OpenAI-compatible - GIVEN `LLM_PROVIDER` is set to `"openai-compatible"` - WHEN the route resolves the adapter - THEN the OpenAI-compatible adapter is returned #### Scenario: Adapter selected by config — Gemini - GIVEN `LLM_PROVIDER` is set to `"gemini"` - WHEN the route resolves the adapter - THEN the Gemini adapter is returned #### Scenario: Gemini adapter synthesizes tool-call IDs - GIVEN the Gemini API returns a function call response with no call ID - WHEN the Gemini adapter maps the response to `ProviderResponse` - THEN each `ToolCall` in `toolCalls` has a non-empty `id` field generated by the adapter ### Requirement: Provider Configuration Server-only environment variables `LLM_PROVIDER` (required), `LLM_MODEL` (required), `LLM_API_KEY` (required), and `LLM_BASE_URL` (optional) MUST determine which adapter and model the route uses. `LLM_PROVIDER` MUST accept at least `"openai-compatible"` and `"gemini"`. When `LLM_PROVIDER` is `"gemini"`, `LLM_BASE_URL` MUST be ignored. Configuration MUST be read server-side only — never exposed to the client. #### Scenario: Missing required configuration - GIVEN `LLM_API_KEY` is not set in the environment - WHEN the route attempts to initialize the provider - THEN the call returns 502 with a missing-configuration error #### Scenario: Unknown provider - GIVEN `LLM_PROVIDER` is set to `"unknown-provider"` - WHEN the route resolves the adapter - THEN the factory throws `"Unknown LLM provider: unknown-provider"` #### Scenario: Gemini config ignores base URL - GIVEN `LLM_PROVIDER` is `"gemini"` and `LLM_BASE_URL` is set - WHEN the Gemini adapter is created - THEN `LLM_BASE_URL` is not used in the API call ### Requirement: Tool Argument Validation Before executing any tool call, the orchestrator MUST validate that each argument key declared `required: true` in the tool's `parameters` is present in the call args and is of the declared type. If validation fails, the tool MUST NOT execute; instead the orchestrator MUST produce a `ToolResult` with `error` describing the missing or malformed argument. #### Scenario: Missing required argument - GIVEN `transcribe_video` declares no required parameters and the LLM returns `toolCalls: [{ name: "transcribe_video", args: {} }]` - WHEN the orchestrator validates args - THEN validation passes and the tool executes #### Scenario: Wrong argument type - GIVEN a tool declares `{ key: "assetId", type: "string", required: true }` - AND the LLM returns `args: { assetId: 123 }` - WHEN the orchestrator validates args - THEN the tool does NOT execute and a `ToolResult` with `error` containing "assetId" is returned ### Requirement: Max Iteration Guard The orchestrator loop MUST enforce a hard iteration cap of **8**. An iteration is one round of "call provider → resolve tool calls → feed results back." If the cap is reached, the orchestrator MUST stop, append a final assistant message indicating the limit was reached, and set `agentStore.status` to `"idle"`. #### Scenario: Cap reached mid-loop - GIVEN the provider has returned tool calls on 7 consecutive iterations - WHEN iteration 8 also returns tool calls - THEN the orchestrator stops, appends "Reached maximum iteration limit" to `chatStore`, and sets status to `"idle"` #### Scenario: Loop completes within cap - GIVEN the provider returns a final text response after 2 tool-call iterations - WHEN the orchestrator processes the response - THEN no cap message is appended and status is `"idle"` ### Requirement: Gemini Free-Form Chat When the Gemini adapter receives a request with no tool schemas (or tools array is empty), it MUST send messages to the Gemini `generateContent` API and return a `ProviderResponse` with `content` set to the assistant text and `toolCalls` undefined. #### Scenario: Plain chat through Gemini - GIVEN `LLM_PROVIDER` is `"gemini"` and messages contain `[{ role: "user", content: "Hello" }]` - WHEN the adapter calls Gemini and receives a text-only response - THEN `ProviderResponse.content` is the assistant text and `toolCalls` is undefined #### Scenario: System prompt mapping - GIVEN the adapter receives `systemPrompt: "You are a video editor"` - WHEN it builds the Gemini request - THEN the system prompt is mapped to Gemini's system instruction field, not as a user message ### Requirement: Gemini Tool Calling When the Gemini adapter receives a request with tool schemas, it MUST convert internal `ToolSchema[]` to Gemini function declarations, call `generateContent` with declarations attached, and if Gemini returns function call parts, map each to a `ToolCall { id, name, args }` with a synthesized non-empty `id`. #### Scenario: Gemini returns function calls - GIVEN `transcribe_video` is in tool schemas and Gemini responds with a function call part `{ name: "transcribe_video", args: {} }` - WHEN the adapter maps the response - THEN `toolCalls` contains one `ToolCall` with `name: "transcribe_video"`, `args: {}`, and a synthesized `id` #### Scenario: Gemini returns text despite tools - GIVEN tool schemas are provided but Gemini responds with only text - WHEN the adapter maps the response - THEN `toolCalls` is undefined and `content` holds the text #### Scenario: Tool schema type mapping - GIVEN an internal tool schema has parameters `[{ key: "assetId", type: "string", required: true }]` - WHEN the adapter converts to Gemini function declarations - THEN the parameter appears in `FunctionDeclaration.parameters.properties` with type `"string"`