feat(api): agent loop + conversations/messages schema (non-streaming) #338

Closed
opened 2026-06-29 00:29:59 +00:00 by james · 0 comments
Owner

The server-side agent loop that ties together the tool registry (#51), the provider adapters (#337), and the propose-then-confirm commit path (#51) — plus the normalized conversations/messages persistence (ADR-0029 §4). Part of epic #47.

Non-streaming for this ticket (a JSON request/response turn). SSE streaming needs an adapter stream() method that #337 deferred, so streaming + the SSE chat endpoint are the next ticket; the chat UI is the one after. Scoping the loop here keeps the hard state-machine + write-pause/resume correct first.

Scope

  • Migration 025_conversations_messages (ADR-0029 §4, both engines):
    • conversations — id, user_id FK (cascade), title (nullable), provider, model, status (active | awaiting_confirmation), created_at, updated_at.
    • messages — id, conversation_id FK (cascade), user_id FK, role (user | assistant | tool), seq (per-conversation order), content (text, nullable), tool_calls (JSON text, nullable), tool_call_id (nullable), created_at.
    • Schema + entities + repositories (conversations: create/get/list/setStatus; messages: append/listByConversation ordered by seq).
  • Agent loop (apps/api/lib/agent/loop.ts) — runTurn(ctx, conversationId, userText):
    1. Append the user message.
    2. Build an LlmGenerateRequest (system prompt + the conversation's normalized history + llmToolDefsFromRegistry() from #51) and call the user's LlmClient (#337 getLlmClientForUser, injectable for tests).
    3. On tool_calls: read tools → execute via the #51 registry, append a tool message with the result, loop again. Write tools → persist a ProposedChange/pending_action (the #51 path), append an assistant message carrying the proposal, set the conversation awaiting_confirmation, and stop the turn (the user confirms via the existing #51 commit/reject endpoints).
    4. Loop until the model returns no tool calls (end_turn) → append the assistant text message → done. Enforce a max-tool-rounds guard.
  • ResumePOST /api/conversations/{id}/resume: after the user commits/rejects the pending proposal (existing #51 POST /api/agent/proposals/{id}/{commit,reject}), feed the corresponding tool result message back and continue the loop. Leaves #51's commit endpoint unchanged.
  • API (JSON, non-streaming): POST /api/conversations (create), POST /api/conversations/{id}/messages (send a user message → run the turn → return the new messages + conversation status), GET /api/conversations (list, cursor), GET /api/conversations/{id} (with messages), POST /api/conversations/{id}/resume. Session or PAT; per-user scoped (cross-user conversation id → 404).
  • System prompt — Carol's persona + voice; explains she acts on the user's own data via tools and that writes are proposed for confirmation, never applied directly.
  • Tests (both engines): a turn with no tools; a read-tool round; a write-tool turn that pauses (awaiting_confirmation + a pending proposal, DB unmutated) then resumes after commit; max-rounds guard; cross-user 404; history replay. Inject a fake LlmClient — no real provider calls. OpenAPI for the new routes; api-client regen.

Acceptance criteria

  • A conversation runs a full turn: user message → tool-using model response → read tools execute, write tools pause for confirmation → assistant reply, all persisted as normalized messages.
  • Writes never apply directly — they pause as proposals; commit (#51) + resume applies and continues. Per-user isolation holds (cross-user → 404).
  • Tests run on both engines with an injected LLM client (no live API). typecheck/lint/semgrep + OpenAPI drift green.

Out of scope

  • SSE streaming + the adapter stream() method + the SSE chat endpoint (next ticket). The chat UI (next-next). Voice. A shared instance LLM key.

Depends on #51 (registry + commit), #337 (adapters), #334 (config). Implements ADR-0029 §2/§4/§6.

The server-side **agent loop** that ties together the tool registry (#51), the provider adapters (#337), and the propose-then-confirm commit path (#51) — plus the normalized `conversations`/`messages` persistence (ADR-0029 §4). Part of epic #47. **Non-streaming for this ticket** (a JSON request/response turn). SSE streaming needs an adapter `stream()` method that #337 deferred, so streaming + the SSE chat endpoint are the **next** ticket; the chat UI is the one after. Scoping the loop here keeps the hard state-machine + write-pause/resume correct first. ## Scope - **Migration `025_conversations_messages`** (ADR-0029 §4, both engines): - `conversations` — id, user_id FK (cascade), title (nullable), provider, model, status (`active` | `awaiting_confirmation`), created_at, updated_at. - `messages` — id, conversation_id FK (cascade), user_id FK, role (`user` | `assistant` | `tool`), seq (per-conversation order), content (text, nullable), tool_calls (JSON text, nullable), tool_call_id (nullable), created_at. - Schema + entities + repositories (`conversations`: create/get/list/setStatus; `messages`: append/listByConversation ordered by seq). - **Agent loop** (`apps/api/lib/agent/loop.ts`) — `runTurn(ctx, conversationId, userText)`: 1. Append the user message. 2. Build an `LlmGenerateRequest` (system prompt + the conversation's normalized history + `llmToolDefsFromRegistry()` from #51) and call the user's `LlmClient` (#337 `getLlmClientForUser`, **injectable** for tests). 3. On `tool_calls`: **read** tools → execute via the #51 registry, append a `tool` message with the result, loop again. **Write** tools → persist a `ProposedChange`/`pending_action` (the #51 path), append an assistant message carrying the proposal, set the conversation `awaiting_confirmation`, and **stop the turn** (the user confirms via the existing #51 commit/reject endpoints). 4. Loop until the model returns no tool calls (`end_turn`) → append the assistant text message → done. Enforce a **max-tool-rounds** guard. - **Resume** — `POST /api/conversations/{id}/resume`: after the user commits/rejects the pending proposal (existing #51 `POST /api/agent/proposals/{id}/{commit,reject}`), feed the corresponding `tool` result message back and continue the loop. Leaves #51's commit endpoint unchanged. - **API (JSON, non-streaming)**: `POST /api/conversations` (create), `POST /api/conversations/{id}/messages` (send a user message → run the turn → return the new messages + conversation status), `GET /api/conversations` (list, cursor), `GET /api/conversations/{id}` (with messages), `POST /api/conversations/{id}/resume`. Session or PAT; per-user scoped (cross-user conversation id → 404). - **System prompt** — Carol's persona + voice; explains she acts on the user's own data via tools and that writes are proposed for confirmation, never applied directly. - **Tests** (both engines): a turn with no tools; a read-tool round; a write-tool turn that pauses (`awaiting_confirmation` + a pending proposal, **DB unmutated**) then resumes after commit; max-rounds guard; cross-user 404; history replay. **Inject a fake `LlmClient`** — no real provider calls. OpenAPI for the new routes; api-client regen. ## Acceptance criteria - [ ] A conversation runs a full turn: user message → tool-using model response → read tools execute, write tools pause for confirmation → assistant reply, all persisted as normalized messages. - [ ] Writes never apply directly — they pause as proposals; commit (#51) + resume applies and continues. Per-user isolation holds (cross-user → 404). - [ ] Tests run on both engines with an injected LLM client (no live API). typecheck/lint/semgrep + OpenAPI drift green. ## Out of scope - SSE streaming + the adapter `stream()` method + the SSE chat endpoint (next ticket). The chat UI (next-next). Voice. A shared instance LLM key. Depends on #51 (registry + commit), #337 (adapters), #334 (config). Implements ADR-0029 §2/§4/§6.
james closed this issue 2026-06-29 01:04:49 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#338
No description provided.