Agent conversations — a conversational, multi-turn agent over the eval control plane
Status: P1–P5 LANDED (local, unpushed) — kernel + contracts/store +
apps/agent+ web panel; all automated gates green (full turbo typecheck+test 70/70, cone, web-imports, web build). Remaining: a LIVE e2e against a running stack + a registered workspace model, and devup.shwiring.Everdict runs and evaluates other people's agent harnesses. This feature adds Everdict's own agent: a conversational, multi-turn, tool-using LLM agent that a workspace member talks to from the web to review harnesses, analyze scorecards / judge traces, and inspect runtime resources — the platform reasoning about its own eval data. The "agent" entity name was deliberately freed for exactly this by the earlier
agent → job-runnerrename (the dispatched worker is now@everdict/job-runner; "agent" = a real LLM agent).The agentic loop is ported from
workspaces/digo-data/apps/digo-agent— a from-scratch, Claude-Code-style agentic runtime (loop + context compaction + tool registry with ToolSearch progressive disclosure + MCP bridge + observability). We bring its domain-agnostic kernel (src/runtime/*) over as a reusable package and leave its digo product domain behind. "Reinterpret, don't copy" (CLAUDE.md §4): the port is adapted to everdict conventions (AppError, Zod boundaries, noany/!,.jsESM imports).
Decisions locked with the maintainer
- D1 — separate
apps/agentserver. The loop runs as its own Fastify service (mirroring digo'sdigo-admin ↔ digo-agentsplit), not folded intoapps/api. It reaches the control plane as an MCP client ofapps/api's/mcp(Streamable HTTP), consistent with CLAUDE.md's "Humans→Keycloak; agents→MCP". The reusable kernel lives inpackages/agent-runtimeso a later in-process host stays possible. - D2 — read-only first. The first slice exposes only read-only control-plane tools (list/get scorecards,
inspect trace, diff, get queue, inspect runtime, list runs, get run logs). Write actions (
run_scorecard,create_comment,control_runtime) come in a later slice behind a permission/approval (HITL) gate — the digopermissionssubsystem is ported then, not now. - D3 — workspace model↔secret binding. The agent's LLM uses the workspace's own registered model + key,
via the existing
ModelSpecregistry +SecretStore(modelConnectionEnv/modelApiKeySecretNameinpackages/domain/src/model/model-binding.ts). The tenant's key is used and cost is attributed to them. - D4 — web surface = a right-panel tab. Agent conversations appear as a new
agenttab in the infra split-view panel (apps/web/src/widgets/infra-panel), next to schedules/runtimes/runs/work — an embedded component (likeWorkTab), not an iframe-hosted page. History is a session list; a session is a streaming multi-turn chat. MVP transport is polling (mirroringwidgets/live-logs), upgradeable to SSE later.
Architecture
apps/web (infra-panel 'agent' tab)
│ BFF proxy /api/agent/* (forwards Keycloak bearer)
▼
apps/agent (Fastify, @everdict/auth → Principal, workspace-scoped)
│ sessions/messages ─────────────► AgentSessionStore (@everdict/db, Pg/InMemory)
│ chat ──runs──► @everdict/agent-runtime (ported kernel: loop · llm · context · tools · mcp bridge)
│ │ LLM ──► workspace ModelSpec + SecretStore key (openai-compatible client)
│ │ tools ──► MCP client ──(caller bearer)──► apps/api /mcp (read-only subset)
▼
apps/api /mcp (existing 121-tool surface; agent reuses it — dogfooding)
packages/agent-runtime — the ported kernel (domain-agnostic)
Depends only on @everdict/contracts + openai + @modelcontextprotocol/sdk + zod. Reinterpreted from
digo-agent/src/runtime/* (its kernel map is faithful to Claude Code):
| area | ported from | purpose |
|---|---|---|
kernel/loop + agenticAdapter + messages + normalize + systemPrompt | runtime/kernel/* | one turn = LLM call → dispatch tool calls → feed results → repeat until end_turn/max_turns/budget/aborted |
@everdict/llm LlmTransport (injected) + llm/summarize | runtime/llm/* | provider-NATIVE transport (Anthropic Messages / OpenAI, native protocol + prompt/KV caching) selected by ModelSpec.provider; stream() for the turn (tool-calling), complete() one-shot for compaction. NOT provider-agnostic-over-LiteLLM — a custom baseUrl = the openai-compatible escape hatch |
context/{tokenBudget,microCompact,compaction} | runtime/context/* | context-window compaction at ~90% budget (micro → LLM → structural) |
tools/{definition,registry,invocation,deferred,ToolSearchTool,openai} | runtime/tools/* | tool contract + registry + ToolSearch progressive disclosure (deferred MCP tools stay hidden until discovered — critical with ~121 MCP tools vs. context budget) |
mcp/{client,bridge,discovery} | runtime/mcp/* | bridge MCP tools → ToolDefinition (marked deferred); the transport/session is injected by the host |
Deferred for later slices (not ported in slice 1): skills, permissions (read-only ⇒ no HITL yet), tasks,
work-plans, memory, replays, evals, digo data-sources / agents domain.
apps/agent — the server
- Auth:
@everdict/authcomposite (Keycloak JWT +ak_API key) →Principal{subject,workspace,roles}. - Routes:
POST /agent/sessions,GET /agent/sessions,GET /agent/sessions/:id,GET /agent/sessions/:id/messages(supports?since=),POST /agent/sessions/:id/chat. - Tools: on chat, open a
ResilientMcpSession(pattern frompackages/self-hosted-runner/src/runner-session.ts) toEVERDICT_MCP_URLforwarding the caller's bearer, list tools, keep the read-only allowlist, bridge them into the runtime registry (deferred + ToolSearch). - LLM: resolve the workspace's chosen
ModelSpec(model registry) → provider/baseURL/underlying model + decrypted key fromSecretStore→ construct the openai-compatible client for the kernel. - Persistence:
AgentSessionStore(new,@everdict/db) —everdict_agent_sessions+everdict_agent_messages(migration0066), workspace-scoped, modeled onRunStore/CommentStore. - Composition root
main.tsmodeled onapps/api/src/main.ts(env → stores → services →buildServer→ listen).
apps/web — the panel
widgets/infra-panel: add 'agent' to the InfraTab union (model/infra-panel-context.tsx), a rail entry
(ui/infra-rail.tsx TABS), TAB_META + a render branch (ui/infra-panel.tsx) → embedded <AgentChatPanel/>.
New entities/agent-session (zod) + features/agent-chat (session list + chat view + input; polling hooks like
widgets/live-logs). BFF proxy app/api/agent/* → apps/agent (AGENT_URL env) via shared/lib/control-plane.ts.
i18n agentChat namespace in messages/{en,ko}.json.
Phased roadmap
- P0 — this doc +
packages/agent-runtimescaffold. ✅ - P1 — port the kernel into
packages/agent-runtime(loop/llm/context/tools/mcp), unit-tested. ✅ - P2 —
AgentSessioncontracts +AgentSessionStore(InMemory/Pg + migration 0066). ✅ - P3 —
apps/agentserver:/meidentity, session/message routes, chat loop, MCP read-only tool bridge, model binding. ✅ - P4 — web: infra-panel
agenttab +features/agent-chat+ BFF proxy (/api/agent/*,AGENT_URL) + i18n. ✅ - P5 — automated gates green (turbo typecheck+test 70/70 · cone · web-imports · web build). ✅
Remaining: LIVE e2e against a running stack + a registered workspace model; dev
up.shwiring;ci:localbefore push. - P6 (post-v1 polish, landed) — session delete UI; live tool activity: the loop persists each
assistant/tool turn as it is produced (
onMessage) and the web polls/messages?since=during a turn, so tool calls/results show live (collapsible rows) rather than only after the turn settles. Gap pass vs digo-agent +workspaces/claude-code— fixed: tool-only assistantcontent:null(not"", which some providers reject),producedaccumulated on append (compaction-safe, not a tail slice), transient upstream retry (429/5xx/network, same model, fixed backoff), tool-output cap 24k→48k chars. (Assessed-but-not-a-bug for this loop's control flow: abort/budget dangling tool_calls — every exit point leaves a balanced transcript; system-anchor loss — the system prompt is re-added each turn, never stored in the compacted array.) - P7 (frontier-grade UI redesign, landed) — the panel was rebuilt to a Claude/ChatGPT-desktop bar: assistant
turns render markdown (
shared/ui/Markdown) in full-width role rows (an indigo "spark"AgentAvatar, not a robot); tool calls are collapsible cards (JsonView); hover actions (copy / regenerate) with toasts; an auto-grow composer with a Stop button,@-mention, andKbdhints; a suggested-prompt empty state; smart auto-scroll + a scroll-to-bottom pill; date-grouped history with relative time; delete via a styledDialog; tastefulanimate-inmotion — all in everdict's design-system atoms/tokens. - P8 (real-time + rename + attachments, landed) — a turn streams over SSE (content-negotiated on the chat
route,
reply.hijack):deltaevents grow a live assistant bubble,messageevents merge each persisted record. Session rename (PATCH /agent/sessions/:id+ inline UI). File attachments — text files (paperclip / drag-drop) are read client-side and folded into the model context (like @-references); only metadata (name/type/size) is persisted (AgentAttachment, migration 0068). - Live turns are decoupled from the connection (
apps/agent/src/live-turns.ts) — the original P8 coupling ("client disconnect aborts the loop") made switching conversations kill or orphan a running turn: the panel looked idle on return and a re-send double-ran the session. Now POST /chat registers the turn in a per-session LiveTurnRegistry and the SSE response is just its first subscriber: a disconnect only detaches (the loop keeps running headless, records keep persisting), a concurrent /chat on the same session is refused with 409 (the duplicate-turn guard),GET /agent/sessions/:id/streamre-attaches (204 when idle; replays the in-flight assistant/reasoning buffers + parked HITL asks, then follows the broadcast to the terminal done/error), andPOST /agent/sessions/:id/stopis the explicit abort the Stop button now calls. Session responses carry a computedliveflag (wire-only,AgentSessionResponseSchema— never persisted): the history menu shows a running badge, and the panel auto-re-attaches when it opens a live session (a send that 409s restores the input and re-attaches instead of erroring). A parked write-tool approval survives navigation — deny now comes only from timeout or /stop, and the re-attach replay re-renders the prompt. - Long-run resilience (landed — the "frequent disconnects" hardening, gap-analyzed file-by-file against
workspaces/claude-code) — four layers. (1) Transport senses (@everdict/llm, Anthropic):timeoutMsis actually enforced (time-to-headers; it was declared and unused), a 90s stream-idle watchdog cancels a silently-dead SSE body (a dropped connection must not pin the turn forever), a stream that ends with no content/tool_calls/stop_reason fails as a retryable error instead of posing as a completed empty turn, and a rate-limited response surfacesRetry-After/anthropic-ratelimit-unified-resetasextra.retryAfterMs. The OpenAI-compatible transport remaps too (it used to let the raw SDKAPIErrorthrough): status +retryAfterMsinextrafor the retry policy to classify on, the parsed response BODY in the message — a 429 that saysusage_limit_reached … resets_in_secondsis the member's whole diagnosis. (2) Retry policy (kernelrunAgentLoop): exponential backoff + jitter (500ms base → 32s cap, default 6 retries — was 2 fixed), the server's own pacing honored over the computed backoff, retryability decided by the TRUE upstream status (extra.status— a provider 400 is no longer retried just because UpstreamError maps to 502),retryevents surfaced per wait, andpersistentRetryfor unattended turns (teammate / discussion / report set it via a deps spread): capacity errors (429/529/overloaded) are waited out indefinitely (5min-cap backoff) instead of failing the reaction. An ATTENDED turn does the opposite pastINTERACTIVE_RETRY_AFTER_MAX_MS(30s): a server pacing longer than a person can wait ends the turn immediately — a plan quota that resets in hours/days is not something backoff fixes, and parking the chat behind a retry banner for it is the failure, not the recovery. The thrownUpstreamErrorcarries the provider's own reason in its MESSAGE (not only inextra.detail): "the model provider call failed" alone cannot be told apart from a dead key, an exhausted quota or a network blip, and it is the string the member reads. (3) Transcript repair (normalizeHistory— Claude Code'sensureToolResultPairingreinterpreted): a crash-dangling assistanttool_callsis answered with a synthetic tool result on replay and orphan tool results are dropped, so a mid-turn host death no longer bricks the conversation with provider 400s forever. (4) Failure is a conversation citizen (runChat): a failed turn persists WHY as an assistant record before rethrowing (the transcript shows it; the next message just continues) — for the WHOLE turn, not just the loop, so one that dies before the first model call (no model configured, an unreachable model registry, a dead tool session) can no longer read as the agent ignoring the member — and the same cause isconsole.error-logged for the operator (a turn that died on the provider used to leave no trace at all in the service log). Usage metering moved to afinally(a failed/aborted turn's consumed tokens still bill), and the chat MCP invoke now reconnects a dead session (makeInvokeover a client box; shared in-flight reconnect — reads auto-retry once on the fresh session, mutations return an explicit outcome-unknown error instead of risking a silent double-fire). Also in this pass: a turn's write tools run serially in call order while consecutive read-only calls stay concurrent (the isConcurrencySafe partition, overisReadOnly); the default output cap rose 4096→8192 withfinishReasontruncation surfaced as atruncatedevent;outputTokensis a loop/stream option end-to-end. The panel RENDERS the resilience:retry/fallbackflow through the SSE feed (the live-turn snapshot replays an in-progress wait to re-attachers) as an amber notice, so a minutes-long backoff reads as "waiting out a capacity limit, attempt N" instead of a frozen turn. And the permission-mode picker applies MID-TURN: the permit hook re-reads the session's mode per ask (an explicit per-turn body.mode still pins the whole turn; plan stays turn-scoped), and PATCHing the mode resolves already-parked asks the new mode would never have asked (bypass → all, auto → the non-guarded). - Gap round 2 — unwired assets (landed): the second claude-code parity pass found the loop itself at parity
and the gaps OUTSIDE it — mostly existing everdict assets never wired into the conversation. Three wirings +
one kernel addition: (1) knowledge auto-recall — a turn carrying @-references asks
get_task_contextONCE with the references mapped to knowledge-node refs (the reference IS the anchor; its version IS the as-of coordinate) and folds the workspace's claims/decisions/conventions into the preamble (Claude Code's relevant_memories, reinterpreted as anchored recall instead of embedding search; best-effort, no-reference turns recall nothing). (2) Crafted agents as spawnable sub-agent types —registrySubagentTypesmaps the workspace's registered agents (instructions = role prompt) intospawn_agent(subagent_type)roles, merged after the builtins (name collision keeps the builtin; the chat's own config agent excluded; tools stay the read-only sub-agent surface; model tier defers to subagentModel). (3) Averifybuiltin sub-agent type — Claude Code's verification agent reinterpreted: refute-first, verdicts must cite read-tool outputs, and the read-only toolset is framed as the verifier's qualification. (4) Structured output (kernel):outputSchemaregisters astructured_outputtool whose parameters ARE the schema; the submission ends the run with the value onAgentLoopResult.structuredOutput(one nudge if the model finishes without submitting) — for programmatic hosts (activations, reactions, evals). (5) Session running memory (Claude Code's session memory reinterpreted; mig 0101):AgentSessionRecord.memory+memoryThroughSeqhold a rolling digest of the conversation's oldest records, maintained bymaintainSessionMemoryat successful turn boundaries — once the bounded replay span outgrows ~100k chars, the records beyond the recent-20 working set are folded (cut at a clean USER boundary so the tail replays balanced) into a fresh digest SEEDED with the previous one (memory only rolls forward; a declining summariser keeps full replay). The next turn replays digest + uncovered tail instead of the whole history — a long conversation stops re-reading (and re-compacting) its entire past every turn. In-run compaction is unchanged and complementary: compaction fits ONE run's context; memory bounds what every FUTURE turn replays. (6) Stale-file reminders (staleFileReminder— Claude Code'sedited_text_fileattachment reinterpreted over the revision ledger): at the turn boundary, the files this conversation touched (get_file/write_file calls in the replayed transcript, most-recent 8) are checked against the ledger's newest revision (list_file_revisionslimit 1, parallel, best-effort); a revision published AFTER the conversation's last touch by someone ELSE — a member, or an agent in ANOTHER conversation (actor.conversationIddecides) — earns a preamble warning naming who/when/why, so the agent re-reads before relying on or overwriting stale knowledge. The mid-conversation counterpart of the write path's 409 + three-way merge; zero ledger calls for conversations that never touched a file. (7) Soft interrupt (kernel + seam; Claude Code's ESC reinterpreted):onInterruptReadyhands the host a trigger that aborts only the IN-FLIGHT step — each model attempt and the whole tool dispatch run under a per-step AbortController linked to the run signal, and a retry wait breaks early. An interrupted model call appends nothing (balanced); an interrupted tool batch closes its pairing with synthetic results (settled tools keep their real result; in-flight → outcome-unknown error; never-started → not-executed). Resolution at the now-balanced boundary: queued input (drainInput) → the turn continues REDIRECTED; nothing queued → stopReasoninterrupted(stop and wait for the user — a bare ESC).LiveTurnRegistry.setInterrupt/interruptparks the trigger per turn (stop() stays the whole-turn abort); ChatHooks.onInterruptReady forwards it. REMAINING WIRING (blocked on the concurrent server.ts run-ledger refactor): the chat route passingonInterruptReady: (fn) => liveTurns.setInterrupt(ws, id, fn), aPOST /agent/sessions/:id/interruptroute (visibility-aware like /stop), and the web's queue-then-interrupt composer flow (POST /input then /interrupt while a turn is live). (8) Agent-plane metrics (ChatDeps.metrics→ the shared zero-dep Prometheus registry, exposed as GET /metrics on the agent server by main.ts): the loop's resilience events become measured series —everdict_agent_turn_total{outcome}(incl. a thrown turn as outcome="error"),everdict_agent_turn_seconds(success or not),everdict_agent_retry_total {persistent},everdict_agent_fallback_total,everdict_agent_truncated_total,everdict_agent_compaction_total{mode},everdict_agent_tool_result_total{ok}— so "why do turns die" is a distribution you scrape, not an anecdote (the measurement gate the gap analysis put BEFORE streaming tool execution). Deferred from the same analysis: task ledger (agent-teams), streaming tool execution (deliberately last — Claude Code's inc-4258 double-execution scar interacts with our non-streaming retry ladder). - P9 (per-workspace customization, landed — Phase 1) — each workspace can enhance its own agent, plugging its
context + tools into the shared framework the way Claude Code takes a per-project CLAUDE.md + MCP servers. A new
registered, versioned
AgentSpecentity ((tenant, id, version) → AgentSpec, same immutable-version SSOT as harness/judge/model: owner-first +_sharedfallback, soft-delete tombstones) carries three channels:instructions(appended to the base system prompt — persona + tool protocol stay fixed),mcpServers[](workspace MCP tool servers connected alongside the built-in read-only tools;authSecretnames a SecretStore key resolved to a verbatimAuthorizationvalue at connect time;writeis a per-server opt-in — the built-in Everdict tools stay read-only, a workspace's own server MAY mutate), and amodeloverride (which registered model powers the agent). Skills are Phase 2. Surfaces:POST/GET /agents+PUT /agents/:id(save-upsert, auto version-bump) +DELETE …+POST /agents/validate, with MCP parity (list_/get_/create_/validate_/delete_agent;save_agentstays HTTP-only, mirroringsave_model); authzagents:read(viewer+) /agents:write(member+) /agents:delete(admin, creator exception in the service). The agent server resolves(workspace, AGENT_CONFIG_ID ["default"])→ anAgentProfileper turn (apps/agent/src/profile.ts): base prompt + instructions, the workspace MCP servers (secrets resolved, read-only unless write), and the model override; an unregistered workspace gets the base agent unchanged. Web: Settings › Agent (features/manage-agent) edits the workspace's default agent (instructions · MCP servers with aSecretPickerauthSecret + write toggle · model picker). Migration0070. - P10 (workspace Skills, landed — the members author them, not imported) — a workspace builds up its own library
of SKILL.md-style procedures the agent follows for recurring tasks (scorecard-triage, harness-review, …), the
third Claude-Code channel (context ✓ · tools ✓ · skills). Decisions: skills are authored in-workspace (not a
filesystem port), instructions-only (v1 — no executable code; actions come from MCP tools), generation lives in a
web wizard, and sharing is private → workspace (
visibility private|workspace, the Views/browser-profile pattern — a member drafts privately then "shares to the workspace", managed creator-or-admin). A store-backedSkillentity (SkillRecord{name, description, instructions, files, visibility, createdBy};SkillStoreInMemory/Pg, migrations0071+0080;SkillServicewith the per-visibility gate) — NOT versioned (skills are living docs, edited in place, unlike the immutable AgentSpec/Model). Surfaces:POST/GET /skills(+GET /skills/:id) +PATCH(edit / share = visibility toggle;filesreplaces the whole set when present, omitted = kept) +DELETEPOST /skills/generate(skill-generate — a description + a registered model id → an AI-drafted {name, description, instructions, files} via the workspace's model + key, reusing the model-probe connection resolution; the draft persists nothing), with MCP parity for CRUD (list_/get_/create_/update_/delete_skill; generate stays HTTP-only, an interactive flow). authzskills:read(viewer+) /skills:write(member+, creator-or-admin per skill in the service). A skill is NOT one giant document (the Claude Code skill-directory reinterpretation): the SKILL.md body (instructions) stays a lean procedure, and long reference material lives infiles[{path, content}] — relative forward-slash paths, no traversal, ≤32 files × ≤256 KiB, unique paths (contract-validated). The agent consumes skills via three-tier progressive disclosure (@everdict/agent-runtimebuildSkillTools): (1) theuse_skilltool description lists every skill (name + when-to-use) under a Claude-Code-parity listing budget (250 chars/entry, 8 000-char budget, even shrink → names-only floor — a skill is never dropped from the listing); (2)use_skill(name)returns the body plus a paths+sizes index of its files; (3)read_skill_file(skill, path)loads exactly one file at the step that needs it (the tool exists only when some skill bundles files). Loaded-skill payloads survive microcompact within a 24 k-char budget, newest first (Claude Code's post-compact skill re-emission, reinterpreted) — an agent mid-procedure does not lose its instructions. The profile resolver loads the caller's visible skills (workspace-shared + own private) each turn, independent of the AgentSpec; capability-store skill specs (SkillCapabilitySpec) carry the same optionalfiles. Web: Settings › Skills (features/manage-skills) — the list grouped Personal drafts / Workspace skills + a New-skill dialog with an AI generate wizard (describe → draft [may include files] → edit → save) + a private↔workspace share toggle + delete (creator-or-admin), and a detail pagesettings/skills/[id](SkillDetail): SKILL.md + per-file tabs (markdown rendered, others mono), meta strip, and "Edit with agent" as the primary editing path —AskAgentButton(widgets/infra-panel) opens the right-hand agent chat panel with the skill @-referenced (skillis anAGENT_REFERENCE_TYPESmember, resolved viaget_skill) and a draft prompt pre-typed (askAgent(prompt, ref)on the panel context; nothing auto-sends), so the agent reviews and applies edits throughget_skill/update_skillunder the conversation's permission mode (HITL). The manual editor dialog stays as the secondary path (files are listed/removable there; file content authoring is the agent's job).
- P8 — a workspace's configured integrations are usable by default (HITL-gated). Beyond the read-only
allowlist, the agent bridges a curated set of "use the integration" actions from the base control-plane
surface by DEFAULT (
apps/agent/src/mcp-tools.tsINTEGRATION_ACTIONS):post_mattermost_message(new — post to the workspace's configured channel),open_ci_setup_pr,get_image_push_credentials. Each is bridgedisReadOnly:false, so the existing HITL permission gate approves every call inline. Deliberately narrow — config/register/destroy (set_/probe_/remove_/assign_/link_/start_) and secret writes stay excluded, so default-deny still holds for every other mutating verb. This removes the friction of an admin hand-registering awrite:trueAgentSpec.mcpServersentry just to let the agent act on integrations the workspace already set up.post_mattermost_messageis a new control-plane capability (route + MCP tool +mattermost:postmember action) overMattermostService.postMessage— the one genuine gap, since posting to Mattermost previously existed only as the internal completion-notification path. - P11 (reasoning + grouped tool activity, landed) — the transcript now renders the agent's thinking and folds
the noise. Reasoning end-to-end:
@everdict/llmcaptures a turn's reasoning on both providers — Anthropicthinking/redacted_thinkingblocks (streamedthinking_delta/signature_delta) and OpenAI-compatiblereasoning_content— and returns it asStreamResult.reasoning(display text) +reasoningBlocks(native blocks). Extended thinking is opt-in:StreamRequest.thinking={budgetTokens}(wired through the loop'sthinkingoption and the agent'sAGENT_THINKING_BUDGETenv) enables Anthropic thinking, bumpsmax_tokensabove the budget, and dropstemperature(Anthropic rejects a non-default temperature with thinking); reasoning models reason regardless, so capture is always on. The kernel attaches the reasoning to the assistant message as a side-channel (ReasoningCarrier): the displaytextis persisted (AgentMessageRecord.reasoning, migration0074) and the nativeblocksare re-sent verbatim on the following tool-result call so Anthropic's "preserve the thinking block when tool_use follows thinking" holds within a turn (the OpenAI transport strips the side-channel — stateless). The loop emitsreasoning_deltaevents → SSEreasoning→ a live, foldable ReasoningBlock in the panel. Grouped activity (web):buildTranscriptfolds the flat message list into render items — assistant text + user turns are role rows; a run of consecutive tool calls collapses into ONEToolGroup("Used N tools", one click to expand); awrite_todoscall surfaces as a dedicated TodoList checklist (plan / progress) instead of a raw tool card; reasoning is its own block. Tool cards no longer repeat the avatar per turn, so a long tool-heavy turn stays compact. - P12 (analysis artifacts, landed — backend) — the agent emits durable, declarative artifacts
(chart/table/report) instead of ephemeral chat text: native per-turn emission tools
(
apps/agent/src/artifact-tools.ts—render_chart/render_table/write_report, spec-validated via@everdict/contractsparseAnalysisArtifactSpec,isReadOnly:trueper the write_todos precedent), persisted on the conversation (AnalysisArtifactStore, mig 0077), streamed live (SSEartifact) and listable (GET /agent/sessions/:id/artifacts). Never active content (no HTML/JS). Web rendering + View pinning land with the Analysis Studio — seedocs/architecture/analysis-studio.md(the SSOT for this direction). - P13 (bridge-all mutations + permission modes, landed) — the agent can act on EVERY everdict entity: the base
surface is the whole control-plane MCP catalog (mutations included —
run_scorecard,create_dataset,register_harness,delete_*,set_workspace_*, …), with only the runner wire-protocol tools excluded (apps/agent/src/action-policy.tsisProtocolTool). This SUPERSEDES both the default-deny read allowlist framing and theAGENT_ALLOW_EVAL_DRIVEopt-in (P8's curatedINTEGRATION_ACTIONSsurvives only as the classification that keeps a mintingget_read HITL-gated). Safety is layered, not surface-shaped: the control-plane RBAC bounds every call to the member's role, and every mutation goes through the permission gate under the session's permission mode —default(ask every mutation) ·auto(auto-allow routine mutations, ask only the GUARDED destructive/governance/credential actions —isGuardedAction) ·bypass(never ask — the member's explicit standing choice; session rules are skipped too) ·plan(read-only until the presented plan is approved). The mode is a first-class session field (AgentSessionRecord.permissionMode, mig 0079) picked in the chat header (next to the model picker; a draft's pick rides the lazy session create), resolved per turn asbody.mode ?? session.permissionMode ?? "default"(an explicit body.mode is a one-off override), and the existing session rules ("always allow/deny this tool") still short-circuit the prompt in default/auto. - Later — executable (scripted) skills; autonomous scheduled sweeps (runtime monitor → propose/trigger evals); findings → comments + Mattermost; a fallback model + prompt caching; parallel independent tool calls.
Discussion bridge — @everdict inside a comment thread
Every entity detail screen's comment thread (features/discuss) doubles as a multi-party chat room with the
agent: a member mentions @everdict in a comment and the agent answers in the thread, reading the whole
discussion as context. The raw transcript would swamp a discussion, so the agent comment renders compactly —
a live "doing now" line while it works, then only the final markdown answer — and the full reasoning/tool
transcript opens on demand in the right panel (the normal conversation view), where any member can continue
chatting 1:1.
- An agent's comment is Everdict's, wherever it came from. The bridge is not the only way an agent ends up in
a thread — a triage/crafted agent posts through
create_comment(MCP) orPOST /comments, and it does so with the MEMBER'S own credential, because apps/agent is a token courier. The authenticated subject therefore signed a member's name to words they never wrote. The caller instead declares the agent in the request (thex-everdict-agent-id/-name/-conversation-idheaders the workspace filesystem's revision ledger already reads —fs-actor.ts; MCP reads them once at initialize), andCommentService.createauthors the row asCOMMENT_AGENT_AUTHORwithagentSessionId= that conversation, so the thread shows Everdict with a way back to the reasoning. It is borncomplete(a tool caller already has its answer — no lifecycle to drive). Two consequences follow from the same sentinel: the comment emits nocomment.createdfact (loop guard #1 — a watching agent must never wake on its own answer), andaskAgentfrom a declared agent is a 400 rather than a turn fired on its own comment. Attribution, not privilege: the member's token authorizes the write either way, so a forged declaration can only mislabel the caller's own comment. - Comment row IS the answer.
CommentRecordgainsauthorKind:'agent'+agentStatus(running → awaiting_approval → complete|failed) +agentActivity(machine tokenthinking|writing|tool:<name>, localized by the web) +agentSessionId(mig 0082). The control plane stays the ONLY comment mutator. - Bridge = the report-turn template, detached.
CommentService.create(askAgent)(busy-guard: one live turn per thread, 409) creates the placeholder and fires theDiscussionTurnRunnerport → agentPOST /internal/discussion-turn(x-internal-token), which acks 202 and runs detached (a HITL park can last minutes).runDiscussionTurn(apps/agent) mints anagt_token AS the asker (["read","write"]), runs one cappedrunChatover the thread snapshot (+ the resource via the @-reference channel;schedulehas no reference type → thread-only), and reports progress back overPOST /internal/comment-activity(the/internal/usagetwin) onto the placeholder. - The answer stays in the thread it answers. The placeholder is nested on the ask's anchor
(
trigger.parentId ?? trigger.id— only a top-level comment can be a parent), and thatanchorIdrides on theDiscussionTurnRunnerport into the prompt, because the thread snapshot is deliberately id-less: without it the agent knows no comment id at all, so anycreate_commentit writes can only land top-level, a second conversation beside the one it is in. The prompt therefore forbids re-posting the answer (the turn's final message IS the comment) and names the anchor for any comment it does write. The same gap closes on the activation side: acomment.*fact already carriescommentId/parentIdin its payload, andrenderActivationPromptnow spends one line telling the woken agent to reply with that parent. - One session per thread, reused + workspace-visible. The thread's session
(
visibility:'workspace', mig 0083, owner = first asker) is reused across asks, so the agent keeps the discussion's memory and panel follow-ups.getVisibleSession(owner OR workspace) relaxes the session read / messages / pending / permission / chat lookups — owner-only stays for list/rename/delete/model/mode. - Background HITL. The turn's
permitparks in thePermissionRegistry(now storing name/input +pendingFor(sessionId), longer per-wait timeout) and flips the comment toawaiting_approval; a panel opened mid-turn discovers the ask viaGET /agent/sessions/:id/pendingand answers through the normalPOST .../permission. In-memory: an agent restart mid-park strands the turn (finally→failedcovers the request-severed case). - Web. The composer's
@menu leads with a synthetic@everdictentry (never inmentions[]— it flipsaskAgent); the thread polls a server action while a turn is live so every viewer sees running → final; the agent card's "view details" opens the panel on the session (openAgentSessioncontext +everdict:open-agent-sessionpostMessage), where a watch mode polls/messages?since=+/pending(a background turn streams SSE to nobody —runChatpersists records incrementally by design).
Running it (dev)
# 1. apps/api (control plane) running with a workspace + a registered model (D3), e.g. via the dev stack.
# 2. apps/agent:
CONTROL_PLANE_URL=http://127.0.0.1:8787 \
EVERDICT_MCP_URL=http://127.0.0.1:8787/mcp \
DATABASE_URL=postgres://… # shared with apps/api (sessions + secrets + model registry)
EVERDICT_SECRETS_KEY=… # same KEK as apps/api (to decrypt the model's API key)
AGENT_MODEL=<registered-model-id> \
PORT=8790 \
node apps/agent/dist/main.js
# Dev without a DB / registered model: drop DATABASE_URL/AGENT_MODEL and set
# AGENT_LLM_BASE_URL + AGENT_LLM_API_KEY + AGENT_LLM_MODEL (an OpenAI-compatible endpoint) instead.
# 3. apps/web: set AGENT_URL=http://127.0.0.1:8790 → the infra panel's Bot tab shows conversations.
Verification
- Unit: kernel loop with a fake LLM + fake tool (turn/stop-reason/tool-dispatch); ToolSearch discovery; compaction
threshold.
AgentSessionStorein-memory + fakeSqlClient.apps/agentviabuildServer+inject. - E2E: with
apps/api+ a workspace model + a read-onlyak_key, create a session and ask "summarize my last scorecard's failures" → agent callslist_scorecards/get_scorecard/inspect_traceover MCP → assistant messages stream into the web right-panelagenttab.