Skip to main content

What the app owns, what the agent owns

OpenCouch runs a hybrid architecture around the OpenAI Agents SDK. The application owns lifecycle and ordering: which specialist runs, when memory writes are allowed, when the crisis gate fires, when a session ends. The agents own judgment within their slot: which tool to call, which response style fits this turn, how to word the reply.

We don't reject ReAct — we constrain where it runs. Each specialist is a real SDK agent with attached tools and reasons over them at inference time. What the agent never gets to choose is the outer shape of the turn: it can't skip safety classification, hand off to a different specialist, or decide that a memory write should happen mid-response.


The split

The dividing line is before vs inside the specialist. Everything that decides which specialist runs (or whether one runs at all) lives in the application. Everything that happens while a specialist runs is the agent's call, scoped to the tools the runtime attached. In text, that application layer is the lightweight TextTurnGraph: it turns crisis state, triage output, grounded lookup intent, memory-control state, and guided-exercise lifecycle into one route plan before any specialist executor runs.

OUTER LAYER

App-owned

Lifecycle and ordering. Deterministic. Cannot be skipped or reordered by the model.

  • Safety classificationRuns before any specialist is built
  • Specialist selectionTherapeutic / Crisis / Guided Exercise
  • Memory mutation gatingWhen writes are allowed, what is held
  • Exercise stateConsent, current step, exit semantics
  • Persistence & auditThread locks, crisis log, finalization
INNER LAYER

Agent-owned

Judgment within the assigned specialist slot. ReAct loop over attached tools.

  • Which tool to callMemory, grounded lookup, response-style, exercise tools
  • Response style choicesupportive · reflective · clarifying · psychoeducation · technique · closing
  • Response wordingDrafting the actual prose for the user
  • In-slot reasoningWhen to look up a hotline, when to record exercise progress

The agent runs inside a slot the app defined. The outer ring decides who runs and when; the inner ring decides what to do once selected.

The boundary isn't ideological. It's wherever a wrong choice would be worse than the LLM's judgment is good enough to justify — which is almost always at the ordering layer in a mental health product.


What stays app-owned

These responsibilities live in agent/runtime/ and the orchestration helpers around it. The runtime calls into the SDK to execute a chosen specialist; it never asks the SDK "which specialist should I be?"

Safety classification. Crisis assessment runs before any specialist is built. There is no serving path that reaches ordinary response generation without going through the gate. Implementation: agent.guardrails for the classifier, with the runtime branching on its output in agent/runtime/openai_text_runtime.py.

Specialist selection. The runtime picks the specialist agent deterministically — THERAPEUTIC_AGENT_NAME, CRISIS_AGENT_NAME, or GUIDED_EXERCISE_AGENT_NAME — based on triage output, crisis level, and active exercise state. See the explicit selected_agent= calls in OpenAITextRuntime (agent/runtime/openai_text_runtime.py). Specialist handoff_description fields exist on the agent definitions but they aren't used as a model-driven routing mechanism; the app makes the call.

Memory mutation gating. Whether a turn can write memory, what candidates are held, and how session-end consolidation runs is orchestrated by agent.runtime.memory_context and agent.runtime.session. The therapeutic agent has memory tools attached, but the runtime decides when those tools are allowed to mutate state through prompt context and pending-action gates.

Guided-exercise state. Consent, current step, exit semantics, and completion all live in state["exercise_state"]. The guided-exercise agent receives that state in its prompt and is instructed not to invent steps or change the active exercise. See the runtime's _load_and_prepare_guided_exercise flow and the agent instructions in agent/specialists/guided_exercise.py.

Persistence, audit, and session lifecycle. Thread locks, app-owned state snapshots, active-session windows, crisis-audit writes, and session finalization are all PersistentAgentRuntime concerns. None of them are choices the SDK runner can defer or skip.


What stays agent-owned

These are SDK agents in the standard sense: an Agent definition with attached tools, instructions, and a model. Once selected, each specialist reasons over its slot.

Therapeutic agent (agent/specialists/therapeutic.py). Inside a safe turn it chooses among:

  • show_saved_memory / show_memory_status (when the user asks)
  • load_therapeutic_response_skill with a style argument (supportive, reflective, clarifying, psychoeducation, technique, closing)
  • answer_grounded_lookup (when the user asks for current or source-backed information)
  • list_guided_exercise_skills (when considering offering an exercise)

The agent ReActs over these tools at inference time — that's genuinely the LLM's call, shaped by instructions but not enumerated by the runtime.

Crisis agent (agent/specialists/crisis.py). On a turn already classified as level 2/3, the crisis specialist owns the wording and chooses between:

  • lookup_crisis_resources to find region-appropriate hotlines
  • get_crisis_support_template for deterministic safety scaffolding

The classification was app-owned; the response shape is the agent's.

Guided-exercise agent (agent/specialists/guided_exercise.py). Given runtime-provided exercise state, the agent chooses when to call:

  • load_guided_exercise_skill for the next step's guidance
  • record_guided_exercise_progress when the user's reply changes state (complete, partial, hold, stuck, exit, unsafe)

The agent never picks the exercise or invents steps; the runtime validates progress and computes the next step.

Triage agent (agent/specialists/triage.py). The clearest example of the hybrid: triage is itself an LLM-judgment step (returning a structured dispatch decision) but the runtime is the one that invokes triage and acts on its output. The agent decides the route; the app decides what to do with the route.


Why split it that way

Each boundary exists because of a specific failure mode that's unacceptable in this domain — and a specific kind of decision that genuinely benefits from LLM judgment.

Crisis classification is app-owned because the only acceptable outcome is that it runs before any specialist. The failure mode is "a user in crisis gets a therapeutic reply instead of crisis routing and resources." Prompt hardening can reduce that risk; an architectural invariant eliminates it.

Specialist selection is app-owned because the routing decision is small, structured, and has user-visible cost when wrong. Handing "which specialist owns this turn?" to the model adds variance for no real upside — the runtime already has the triage output, the crisis level, and the exercise state.

Memory mutation is app-owned at the gate because the failure mode is silent corruption: a write that lands on the wrong owner, runs before the response is finalized, or never runs at all because the model didn't think to call the tool. The runtime owns when a write is allowed; the agent still owns whether to call the tool within that gate.

Guided-exercise state is app-owned because exercises have an explicit protocol (steps, consent, completion) that the agent shouldn't be free to reshape mid-flow. The agent provides wording; the runtime tracks the protocol.

Tool selection inside a specialist is agent-owned because the LLM genuinely benefits from context. Does this user want grounded facts or emotional reflection right now? Should the response open with acknowledgment or move straight to a clarifying question? These are judgment calls that depend on the conversation, not invariants.

Response wording is agent-owned for the same reason. The runtime can choose the style scaffold (load_therapeutic_response_skill); it shouldn't be writing the prose.


What this isn't

This isn't a claim that LLMs are untrustworthy or that determinism is universally better. ReAct loops genuinely shine when the task is open-ended, the tool sequence is unpredictable, and the cost of a wrong action is low — web search, code search, exploratory research.

In a mental health support agent, the outer shape of a turn isn't exploratory. The sequence is known, safety ordering is critical, and the cost of a wrong action (missed crisis, corrupted memory, drifted exercise) is high. The inner shape — which tool to call, how to phrase support — is contextual and is where the LLM earns its keep.

The hybrid puts each decision at the layer where it lives best. The app owns what would be unacceptable to let drift. The agent owns what would be wasteful to enumerate.


Where to read the code

  • Runtime orchestration: agent/runtime/openai_text_runtime.py (the OpenAITextRuntime class)
  • Specialist agents: agent/specialists/{therapeutic,crisis,guided_exercise,triage}.py
  • Tool registries (what each agent can reach): agent/tools/{memory,grounded,crisis,therapeutic,guided_exercise}.py
  • State and persistence: agent/state.py, agent/runtime/session/
  • Top-level summary: apps/backend/agent/README.md