PersonalAI has two agent runtimes that share the same safety seams. Which one runs is set by the
per-tenant agent mode (agent_mode), configured in the UI Settings → Agents panel:
- single (default) — the single-agent loop: with Use tools on, the model calls tools, reads the results, reasons, and continues until a final answer — all streamed live.
- multi (ADR-0012) — the multi-agent graph: a LangGraph graph planner → researcher → critic → finalize with a bounded reflection loop and an optional durable human approval gate before the answer is committed. See Multi-agent graph.
- custom — reserved for user-defined agents (future); behaves like the configured graph today.
The legacy agent_graph_enabled boolean still works for env-based config and maps to
agent_mode="multi", but the mode selector in Settings → Agents is now the user-facing control.
Either way, every tool call goes through the gateway (permissions, egress allowlist, schema
validation, risk approval, audit), so acting autonomously stays safe. LangGraph is the orchestration
engine only — graph nodes call PersonalAI's own ModelProvider and ToolGateway seams directly;
LangChain's model/tool abstractions are not used (ADR-0012 load-bearing invariant).
- Ask it to act: "What is 23 × 19?" → it calls the calculator and answers 437.
- Ask it to search: "Search the web for X and summarize" → it calls web_search (egress-gated to DuckDuckGo) and answers with sources.
- Turn on Reasoning to have it think before answering; the reasoning streams and is saved.
user → assemble context (RAG + memory + STM) → agent loop:
┌─> model.stream(tools, think)
│ ├─ reasoning tokens ──► live (Reasoning)
│ ├─ answer tokens ──► live
│ └─ tool_calls ──► gateway.invoke(...) ──► tool result ─┐
└───────────────────────────────(feed result back, loop)───────────┘
→ final answer
- The loop streams each model turn, so reasoning and the answer arrive token-by-token; tool calls are parsed out of the stream (Ollama + OpenAI).
- Tool results are fed back as native TOOL-role messages so the model treats them as data.
- It stops at a final answer or a max-iteration guard.
- The ordered timeline (reasoning → tool call → result → …) is shown per message under Details
and persisted (
meta.trace), so reopening a conversation keeps it.
A follow-up like "and the second one?" has no meaning on its own, so before retrieval the backend rewrites the last user message into a standalone, self-contained request using recent history (one extra tool-free model call) and uses that to anchor RAG/memory retrieval and tool queries. The rewrite keeps the user's language and intent; it does not replace the original question that drives the answer. It's skipped for a first or standalone question (no prior turn), so a plain one-shot question pays no extra latency. When it fires, the assembled context view shows an Interpreted request line. The model is also instructed to reply in the same language the user used.
- Tools run through the gateway: least-privilege permissions, egress allowlist, JSON-Schema I/O, risk approval (HIGH/CRITICAL need approve high-risk), timeout, and an append-only audit (the Activity panel, per chat).
- In the multi-agent graph, a tool call to a non-allowlisted host pauses the run for an explicit decision via the egress-approval gate instead of silently failing.
- Tool output is untrusted data, never instructions.
With agent_mode = "multi" (Settings → Agents, or PERSONALAI_AGENT_MODE=multi), a chat turn runs
as a LangGraph graph instead of the single loop. The graph is defined in
core/src/personalai_core/graph.py:
START → planner → [gather → merge →] researcher → [egress_gate → researcher]
→ critic → (replan → planner) | (revise → researcher) | [verifier →] [human_gate]
→ finalize → END
- planner — one tool-free model call producing a short 1–3 bullet plan; emits a
planstep. It's told a tool-capable researcher (with RAG / KAG / memory) will execute the plan, so it never claims a lack of tools or refuses a private-data question. When multiple retrieval sources are available it also emits aSourcePlan(which sources to query). - gather → merge (only when retrieval sources exist) — multi-source retrieval (#420): the
gathernode fans out over the planner's chosenRetrievalSources in bounded parallel, andmergefuses the results with cross-source RRF + a per-source token budget into one evidence set, streaming unified citations tagged withsource_kind/merged_from. The fused evidence is kept in a distinct state key so a later researcher answer can't clobber it — the critic and verifier judge against it. With no sources these nodes aren't added and the topology is unchanged. - researcher — the single-agent loop (
run_agent), informed by the plan and grounded on the merged evidence; streams reasoning, answer, and tool steps exactly as the single-agent loop does. It is the only agent that uses tools in the loop sense. - critic — one model call reviewing the answer against the retrieved sources as ground truth;
it begins with
OK,REVISE, orREPLANand adds a short explanation, then emits acritiquestep. The critique streams to the reasoning pane only and never modifies the answer — the agents' result stands; their review is shown, not applied. When the critic is the last judge (standard mode, no verifier downstream) it also runs the shared judge fact-check. - bounded evaluator-optimizer loop — on a
REVISEverdict the researcher retries (sound plan, poor execution); on aREPLANverdict the graph routes back to the planner (the plan itself was the fault → re-plan + re-retrieve). Both shareMAX_ATTEMPTS = 2(initial attempt + one retry), after which the turn proceeds regardless of the verdict. - verifier (accurate mode only) — a first-class LLM-judge agent that returns a schema-validated
Verdictand runs the judge fact-check; a non-passverdict routes one more researcher pass (sharing the attempt cap). See Verification ladder. - egress_gate (only when a checkpointer is wired) — if the researcher's tools try to reach a non-allowlisted host, the run pauses for egress approval before that call is allowed. This is the second durable gate. See Durable gates.
- human_gate (only when the durable answer gate is on) — suspends the turn before
finalizefor human approval of the answer. - finalize — emits the single terminal answer.
The current date is injected up front (a system message) so every agent is date-aware and won't dismiss recent dates or facts as fabricated.
Because the graph adds a planner call and a critic call (and possibly one researcher retry) around
the normal loop, it makes ~2+ extra model calls per turn and is noticeably slower. Keep it on
single for quick chat; switch to multi when you want a checked, plan-then-review answer.
Each agent (planner / researcher / critic / verifier) has an editable system prompt and, for
the researcher only, per-agent tool/MCP scoping (which tools it may call). The default
prompts are now source-agnostic (not tied to a specific source) and ship in
DEFAULT_AGENT_PROMPTS; an empty override falls back to the default for that agent. The Agents
panel also draws a live collaboration graph showing how the configured agents hand off for the
selected agentic design (single / multi / accurate). Configure these in the UI, or over the API:
curl -H "Authorization: Bearer demo" http://127.0.0.1:8765/api/v1/agents/config # roster + defaults + saved overrides
curl -X PUT http://127.0.0.1:8765/api/v1/agents/config -H "Authorization: Bearer demo" \
-H "Content-Type: application/json" -d '{"agents":[{"name":"researcher","prompt":"..."}]}'The config is per-tenant; unknown agent names are rejected. Prompt overrides and the researcher's
disabled tools are loaded only on the graph (multi) path — single mode uses all tools and no
agent personas.
With the graph on, the per-message Details trace shows the extra steps, color-coded (no emoji):
| Step | Color | Meaning |
|---|---|---|
| Planner | blue | the plan |
| Thinking | gray | researcher reasoning |
| Tool | violet | a gateway tool call |
| Result | green / red | tool success / failure |
| Critic | amber | the critique |
| Verify | rose (verdict word green / red) | the verifier agent's identity; the verdict keeps pass=green / fail=red |
The multi-agent graph has two durable human-in-the-loop gates. Both use the same durable
machinery — a LangGraph interrupt() plus the tenant-scoped checkpoint
(TenantCheckpointSaver, migration 0014_agent_checkpoints.sql, Postgres RLS), so a suspended run
survives a restart and stays tenant-isolated. Both resume through the same endpoint,
POST /api/v1/chat/{run_id}/resume, which dispatches on the gate's reason read from the
checkpoint (never from the request body). On resume, a fresh SecurityContext is in scope
(with CSRF in hosted mode), the checkpoint loads only under the resumer's tenant (a foreign
run_id → 404, cross-tenant resume is impossible), and the run may be resumed only by the
same subject that started it (a different subject in the same tenant → 403).
PERSONALAI_AGENT_HUMAN_GATE=true (requires PERSONALAI_AGENT_GRAPH_ENABLED=true / agent_mode=multi
and a reachable Postgres) inserts the human_gate node before finalize. Each turn then
suspends after the critic and waits for you to approve or reject the answer:
- The backend emits an
approval_requestSSE frame ({run_id, reason:"approve_answer", answer, critique}) and the stream ends without adoneframe. - The UI shows the answer + critique with Approve / Reject controls.
- Resume with body
{decision: "approve" | "reject", conversation_id?}.
The user-visible flow is: plan → answer + tool steps → critique → approve/reject → final answer.
This gate needs no flag — it is always armed whenever the graph runs with a checkpointer (a reachable Postgres). It turns a blocked outbound call into an explicit, blocking decision instead of a silent tool error (ADR-0013):
- When a researcher tool's outbound call targets a host that is not on the tenant's egress
allowlist, the agent loop yields an engine-agnostic
egress_blockedevent and returns (it does not re-prompt the model). The graph routes to theegress_gatenode, which callsinterrupt()to pause the run durably. - The backend emits an
approval_requestSSE frame with{run_id, reason:"egress_approval", blocked_host, tool, args}(args are persisted from the checkpoint; the SSE payload is whitelisted to exactly these client-facing keys). The stream ends without adoneframe. - The UI shows four choices:
- Allow once (
egress_allow_once) — permit this host for this run only; the host is added to a non-persisted config copy. - Allow always (
egress_allow_always) — persist the host to the tenant egress allowlist (tenant-scoped + audited) and enable egress, then resume. - Don't allow (
egress_deny) — resume with the egress denial; the blocked call returns its error to the agent and the turn continues without it. - More info — reveal the redacted outbound args (secret-looking keys —
authorization,token,api_key,password,cookie,bearer, … — are deep-redacted before display).
- Allow once (
- Resume with body
{decision: <egress verb>, conversation_id?, provider?}. Because an egress resume re-runs the researcher (a real model call), the UI sends the turn's originalproviderso the retry runs on the same model. - On an allow, only the blocked tool is retried — the prior succeeded tools are already in the checkpointed conversation as TOOL-role messages and never re-fire. The run may suspend again at a later blocked call.
Why the gate is durable + server-trusted, not a client toggle:
- The blocked host comes from the checkpoint, never the request body — a client sends only the verb, so it cannot smuggle in an arbitrary destination.
- The per-call SSRF guard still runs after any allow. Allowing a host only adds it to the
allowlist set; the tool's public-host check still refuses loopback, RFC1918, link-local/metadata
(
169.254.169.254), and other non-public addresses. "Allow always" on a metadata IP is still blocked at fetch time. - The allowlist write happens in the backend (tenant-scoped + audited). No graph node touches
the database — the
egress_gatenode only callsinterrupt()(ADR-0012 seam: the engine gets no privileges of its own).
The user-visible flow is: plan → researcher hits a blocked host → pause → allow-once / allow-always / deny / more-info → (on allow) retry just that call → continue → answer.
Set it per-tenant in Settings → Agents (preferred), or via env for the boot default:
# Multi-agent graph, no checkpointer (no durable gates; works without a DB):
PERSONALAI_AGENT_MODE=multi make run-backend
# Graph + Postgres: the egress-approval gate is armed automatically (no flag needed);
# add PERSONALAI_AGENT_HUMAN_GATE=true to also arm the answer-approval gate:
PERSONALAI_AGENT_MODE=multi PERSONALAI_AGENT_HUMAN_GATE=true make run-backendagent_mode defaults to single and the answer-approval gate to off. The
egress-approval gate has no flag — it is armed whenever the graph runs with a checkpointer
(a reachable Postgres). The legacy PERSONALAI_AGENT_GRAPH_ENABLED=true still maps to multi for
backward compatibility. See backend API for the exact SSE frames and
the resume endpoint, ADR-0012 for the
orchestration design, and ADR-0013 for the
egress gate.
Per-tenant preferences (model, agent mode, behaviour, embeddings, egress, timeout) are saved via
GET/PUT /api/v1/settings and overlay the boot config for that tenant; an unset field inherits the
deployment default. The env vars below set those deployment defaults (all prefixed PERSONALAI_).
| Setting | Default | Notes |
|---|---|---|
Use tools (use_tools) |
on | enable autonomous tool use |
approve high-risk (approve_tools) |
on | allow HIGH-risk tools (e.g. http_fetch) this turn |
Reasoning (think) |
off | ask the model to think first (slower; needs a thinking model) |
PERSONALAI_AGENT_MODE |
single | single / multi / custom — which agent runtime runs (per-tenant in Settings → Agents) |
| egress | off | PERSONALAI_EGRESS_ENABLED=true + PERSONALAI_ALLOWED_EGRESS_HOSTS=… (also per-tenant in Settings → Network) |
PERSONALAI_OLLAMA_NUM_CTX |
32768 | bounds the context window (KV cache) |
PERSONALAI_AGENT_MAX_ITERATIONS |
8 | safety cap on the single-agent loop |
PERSONALAI_AGENT_TIMEOUT_SECONDS |
300 | whole-turn wall-clock cap (30–3600); the turn fails with a timeout on expiry |
PERSONALAI_AGENT_GRAPH_ENABLED |
false | legacy flag; true maps to agent_mode=multi |
PERSONALAI_AGENT_HUMAN_GATE |
false | with the graph + a DB: suspend each turn for approve/reject |
PERSONALAI_AGENT_ACCURACY_MODE |
standard | standard / accurate — verification-ladder depth |
PERSONALAI_AGENT_VERIFIER_CHECK |
true | arm the judge fact-check (a bounded independent RAG/KAG/memory lookup + a verify-only tool pass) |
PERSONALAI_OLLAMA_IT=1 uv run pytest apps/backend/tests/test_agent_integration.py -q(Opt-in; skipped in CI. Needs a local Ollama with a tools-capable model, e.g. qwen3.)
The accuracy mode (PERSONALAI_AGENT_ACCURACY_MODE, or the per-tenant agent_accuracy_mode
setting) controls how deeply the multi-agent graph verifies an answer:
standard(default) — planner → researcher → critic → finalize, with the bounded reflection loop. Fast: no extra model call.accurate— adds an LLM-judge verifier after the critic. It returns a schema-validatedVerdict(pass/needs_revision/fail, via the bounded, fail-closedgenerate_structuredprimitive) and, on a non-passverdict, routes one more researcher pass (sharing the same bounded attempt cap as the reflection loop) before finalizing. The verdict streams to the reasoning pane as the Verify step (rose identity; verdict word green/red).
Security gates are never accuracy-gated: both durable gates — the answer-approval gate and the egress-approval gate — always run regardless of the verification depth.
Independently of the accuracy mode, the multi-agent graph fact-checks the final answer against fresh, independently-gathered ground truth — not just the researcher's own evidence. The judge runs two bounded, independent checks and judges the draft against what they find:
- a retrieval lookup — one re-query of the wired RAG / KAG / memory sources; and
- a verify-only tool pass — a tiny (
VERIFIER_TOOL_ITERS-bounded) run with the researcher's tools (web / MCP / etc.), prompted to confirm or refute the draft's specific claims, not to re-research. This is what lets the otherwise tool-free judge check tool/web-derived answers, not only source-grounded ones.
Each half is independently fail-open and may return nothing (no sources wired → no lookup; no tools available → no tool pass). Exactly one judge runs the checks per turn: the verifier in accurate mode (it is the last judge), and the critic in standard mode (when it is the last judge), so there is never a redundant second pass.
It is controlled by agent_verifier_check (env PERSONALAI_AGENT_VERIFIER_CHECK, per-tenant in
Settings → Agents), on by default. The lookup is fail-open: any retrieval error falls back to
judging against the researcher's evidence, so it never blocks finalizing.
Third-party MCP servers plug into the same gateway as additional tool sources — sandboxed (ADR-0007) — so "ask it to search / browse / act" extends to the whole MCP ecosystem in either runtime.