Skip to content

Agent logic: hybrid natural-language + deterministic rules

Status: Current | Last reviewed: 2026-10-13

Native Llama Guard and NeMo checks use versioned tenant safety profiles inside existing response guards and tool settings. See native safety profiles for provider contracts, enforcement boundaries and latency limits.

Response guards apply to assistant text. Caller transcription frames pass through whole, including their segment identities, and never enter the assistant's sentence buffer or suppression state.

Topic entry renders a declared variable with a null value as empty text. Unknown variable tags remain literal; false and zero retain their values. This keeps an unrecorded operation receipt visibly empty instead of exposing its template tag as apparent content. Only an actual successful result establishes that the operation completed.

Nova continuation history omits the runtime's top-level instructions field from tool-result envelopes. Current role and procedure guidance remains in the system prompt; historical results retain statuses, errors and record data, including nested clinical instructions. Tool completion alone does not imply business success. The September 30 native pilot received three provider content-filter errors after its first topic handoff; this history correction requires fresh audio verification and does not disable any provider filter.

Variables become eligible for background extraction only with extraction: true; declaring a mutable field does not enable extraction. When a tool binds an eligible field, its gate waits up to two seconds for the latest extraction and any queued correction before checking prerequisites and resolving arguments. Timeout or extraction failure refuses the call, retains the background task, and prevents use of an older value. Ordinary conversation turns remain asynchronous.

Agent Logic lets a non-developer combine natural-language instructions with deterministic behavior (the Agent Script / GECX / Fin-procedures authoring model): typed variables, conditional #when prompt sections re-rendered against live state, deterministic rules at lifecycle triggers, tool gates (conditional availability, bound args, result capture), and response guards that check what the agent is about to say before the customer hears it. Everything below the authoring surface runs on ONE safe expression language shared by every gate in the platform (including the pre-existing warm-transfer handoff rules and workflow trigger conditions).

Execution contracts for writes

config.tool_logic[resolvedToolName] also accepts payment_receipt and idempotency. These run after availability/procedure gates and argument binding, before the underlying real tool or test mock. A refused request returns an ordinary tool result for recovery and never reaches the handler.

{
  "record_copay": {
    "payment_receipt": {
      "transaction_id_param": "transaction_id",
      "amount_param": "amount_dollars",
      "currency_param": "currency",
      "status_param": "payment_status",
      "status_value": "succeeded"
    },
    "bind": {
      "payment_status": {"kind": "literal", "value": "succeeded"}
    },
    "idempotency": {
      "key": ["transaction_id"],
      "fingerprint": ["appointment_id", "transaction_id", "currency", "payment_status"]
    }
  }
}

The payment capability supplies a conversation-scoped status reader. The contract requires exactly one matching stored transaction with status=succeeded and direction=charge, and compares currency and amount in minor units using the payment parser. Missing, pending, failed, cancelled, mismatched or unreadable receipts refuse the write. Model-written variables and caller claims cannot substitute for this read. The existing capture authorization checks still apply. This validates the receipt; the destination remains responsible for the business relationship between the charge and the record being updated.

Idempotency keys and fingerprints use the final bound arguments. Repeated keys share the original result, including callback properties; conflicting business fields refuse execution. Concurrent duplicates wait for the original result. Cancellation or an unconfirmed result reserves the key and requires reconciliation. Captures and result hooks run once. Do not include generated timestamps in the fingerprint. In this payment example, receipt validation already checks amount equality, so formatting differences such as 10 and 10.00 need not conflict.

Replayed results retain the original receipt fields and callback properties, with _execution.replayed: true and an instruction explaining that no additional action executed. Procedure continuation guidance is recomputed from current state. Distinguish repeated model requests from repeated backend writes when evaluating side effects; a replay can still reveal a conversational control problem.

The multimodality guidance switches also govern generic instructions attached to UI actions and their automatic visual-completion retry. Off or scope-disabled guidance preserves the action and its captured values without forcing another screen update. Custom guidance owns the next step; it does not inherit the default render watchdog. Existing procedure gates remain active.

Repaired tool inputs re-enter the current registration, including availability, procedure, payment and capture-mode guards. A missing-input preflight that never dispatched a real tool or mock releases its idempotency reservation. Once dispatch has begun, an unconfirmed outcome requires reconciliation before replay.

This cache is per session, bounded to 256 keys without eviction. It does not provide durable idempotency across process restarts, reconnects or other sessions; destination APIs must enforce that separately. These configuration fields are described in the generated agent schema; there is no additional visual editor control.

One implementation, one mirror

Where What Why
libs/logic-expressions/ (trueserve_logic) The canonical Python implementation — an AST whitelist over Python's own parser, never eval Shared by ai-orchestrator and async-agent via their logic_bootstrap modules (same pattern as libs/knowledge)
apps/ai-manager/lib/logic-expressions/ A hand-written TypeScript tokenizer + recursive-descent parser + evaluator, never eval/Function Editor-side validation, condition-builder round-trip, and preview without a server call

Drift guard: both implementations run the shared conformance fixtures in libs/logic-expressions/fixtures/expression-cases.json — Python via apps/ai-orchestrator/tests/test_logic_expressions.py, TypeScript via apps/ai-manager/lib/logic-expressions/fixtures.test.ts (npm test). A semantic change goes into the fixture file FIRST, then both implementations. A text-read test additionally pins the TS operator/keyword exports against the Python whitelist (same reasoning as test_capability_prompt_tokens.py: importing TS from pytest would need a Node runtime, and a skipped test never catches the drift it exists for).

Grammar

A small Python expression subset:

  • boolean: and or not (short-circuit; and/or return operand values, the gate wrappers apply truthiness)
  • comparisons: == != < <= > >= in not in, chained (0 < score <= 100)
  • literals: strings ('…'/"…"), numbers, True False None, lists, tuples
  • arithmetic: binary +/- and unary minus, Python semantics (bool-as-int, string + concatenation, list/tuple + → list); *// stay rejected
  • names resolve against the variable context; unknown names are None (session variables are sparse by nature — missing_var == "x" is False, not an error)
  • dot-paths (payload.priority) only in the trigger-condition dialect (allow_dot_paths=True); the handoff/agent-logic dialect rejects them

Deliberate edges, pinned by fixtures:

  • ordering across incompatible types (score > "high") is False, never an error — Python's per-operator TypeError catch
  • x in <string> is a substring test and requires a string left operand; in on a non-container is an evaluation error (policy applies)
  • True == 1 is true (Python bool-is-int); a tuple never equals a list
  • a +/- on incompatible types ("a" + 1) is an evaluation error (policy applies) — only comparison ordering gets the silent-False catch
  • no calls, subscripts, multiplication/division, is/is not, or attribute access outside the dot-path dialect

Failure policy is the call site's choice

evaluate_code_rule(expr, variables, fail_open=…, label=…) returns (allowed, error); on a malformed or erroring expression the result is the policy default and error carries a loggable explanation.

Call site Policy Why
Handoff routing rules (handoff_rules.py) fail OPEN a typo in a routing rule must not strand a caller who needs a human
Workflow trigger conditions (conditions.py) fail OPEN a typo must not silently disable a production trigger
Tool available_when gates (Agent Logic) fail CLOSED an erroring gate must not expose a gated tool
Prompt #when sections (Agent Logic) else branch + warning a broken condition renders the fallback text, never breaks the prompt

The LLM-judge rule kind ({"kind": "llm", "prompt": …}) shares trueserve_logic.evaluate_llm_judge — one cheap ALLOW/DENY completion; each call site composes its own system/user prompts verbatim (LLM prompt wording is behavior, so the shared core never rewrites it).

extract_output_path ($.order.items[0].sku) is the deliberately minimal projection used by tool-result capture — not JSONata, which stays an async-agent-only dependency (workflow_expressions.py).

Prompt programs: statements inside the prompt

Agent-Script-style line-by-line prompt construction: three statement forms live directly in template.inline, executed in document order while the prompt is being constructed — a later #when condition or interpolation sees the writes of every statement above it (Agentforce's "constructs the prompt top to bottom" semantics, with the marking INVERTED: prose is the default line and statements are marked, so every existing prompt stays valid):

{{run get_account_info with account_id = account_id set hotel_code = $.data.hotel_code}}
{{set greeted = True}}
The caller's hotel is {{= hotel_code}}.
{{#when hotel_code == "H-VIP"}}Offer the concierge line.{{/when}}
  • {{run tool with input = expr, … set variable = $.path, …}} — invokes the REGISTERED tool handler (gated, healed — the same tool_invoker.py path the rules engine's run_tool uses, never a second invocation path), binds inputs from expressions, captures result paths into variables.
  • {{set variable = expr}} — assigns during construction.
  • {{= expr}} — interpolates the expression's value into the prompt text.

Statement text is CONSUMED, never shipped to the model. The grammar has ONE Python implementation (prompt_program.py: scanner + executor, composed over the when_directives parse tree so statements inside #when arms execute only when the arm is taken) and ONE TypeScript scanner mirror (prompt-program.ts: pills, dialogs, validation — the browser never executes), drift-guarded by fixtures/prompt-program-cases.json run by both suites. Execution fails open prose-first: a failing run/set logs a warning and construction continues; budgets bound the pass (ProgramBudget: 5 runs, 10 s per run, 15 s total). async-agent's llm_agent node runs the same executor side-effect-free (no store, no tool runner — statements still never leak as literal tokens).

Per-provider construction (the S2S matrix)

Topology When a fresh construction ships
Cascade / chat / half-cascade Per turn — the InstructionRefresher swaps the system message by identity
OpenAI Realtime Per turn — plus a live session.update
Nova Sonic Evaluate at caller/assistant turn boundaries and on variable changes. Changed rendered instructions prepare a new session and switch after the current response and pending tools complete. Unchanged instructions keep the session. Age-based continuation still runs before the AWS session limit.
Google ADK Live On every (re)connection — the InstructionProvider callable reads last_constructed_full() before its static string
Platform S2S (ElevenLabs CAI, Deepgram VA, …) Session start only (publish + preflight warn)

This includes conditional sections, prompt programs, and plain variable interpolation. Nova's builder retains the token-resolved template before variable rendering, including templates without #when. InstructionRefresher calls tsf_update_instructions; PromptRefreshingSessionContinuationHelper compares the rendered text with the active session's instructions. It coalesces changes and discards a prepared session if a newer construction supersedes it.

The replacement carries the same tools, bounded conversation history, and recent completed tool receipts as non-interactive historical facts. The existing call, LLMContext, variables, completed-call IDs, and tool registrations stay in place. Preparation runs in the background with a timeout shorter than the audio buffer. The current response and outstanding tools finish before promotion; new caller audio arriving during replay is queued separately and sent to the surviving stream. Preparation failure retains the old stream and retries. Disconnect and guided pause cancel pending preparation. Nova can retain one provider completion across multiple spoken turns. Promotion therefore also accepts a finished audio response whose final captions have caught up with its speculative captions; waiting only for completionEnd can leave changed instructions inactive for the entire call. New caller input, pending output, open content blocks, tools and shutdown still block promotion. A new completion must finish its own response before using this boundary.

This is a turn-boundary refresh, not a guarantee that instructions changing during an answer alter that answer or that the handoff is inaudible. The current response can finish under its original instructions. The old six-minute conditional and prompt-program advisories are no longer emitted for Nova; warnings for providers that only construct at session start remain.

Speculative construction ("pre-fetch, don't pre-commit"): the publisher relays bot-turn-finished to every refresher (notify_bot_turn_finished()), which runs a full construction pass during audio playout, warming a per-refresher RunCache (keyed on (tool, sorted-args json), 30 s TTL) — so the authoritative pass at the next turn is ~free, and last_constructed_full() is fresh when a Nova rotation or ADK reconnect asks for it. Cached results are keyed on the BOUND arguments, so a variable change between passes is a cache miss, never a stale bind.

set_variables (the LLM slot-capture builtin)

An agent that declares MUTABLE variables automatically gets ONE set_variables tool whose parameter schema IS the declarations — one optional property per mutable variable, typed and described from config.variables (why publish requires the description: it is the LLM-facing hint). Writes go through the store, so a saved fact immediately re-renders #when sections, opens tool gates, and feeds prompt programs. set_variables_tool_helper.py, wired in tool phases 1 (schema + auto-injected guidance, override id set_variables) and 2 (registered BEFORE MCP so the name is withheld from the MCP schema); linked/undeclared names are rejected in the RESULT so the model can self-correct.

Runtime enforcement (ai-orchestrator)

One variable blackboard, four enforcement surfaces — all of them wrap mechanisms every provider already honours, and none of them may ever raise into a provider callback (a raising callback tears down a live S2S session):

  • SessionVariableStore wraps the SAME runtime_context["session_variables"] dict every existing reader uses (handoff rules, $var: bindings, IVR goals, prompt variables). Seeded from config.variables at build (setdefault — an inbound CCaaS value beats a config default); linked variables are read-through aliases and refuse writes; change subscriptions drive everything below. Reads (get/snapshot, and linked targets) FALL THROUGH to runtime_context["session_metadata"] for any name not in session_variables, so the canonical session facts (ani/dnis, conversation_id…) — which live in metadata and are never copied into variables — resolve for a bind and for rule/#when expressions exactly as they already do for prompt {{ani}} (which reads build_variable_context = {**metadata, **variables}). A real variable of the same name WINS over the metadata fact (presence, not truthiness — an explicit None still wins, so a secure-captured value is never shadowed); writes go only to session_variables — metadata is read-only session context.
  • #when prompt sections render in PromptResolver.resolve step 2.6 (after the token pass, before Chevron — legacy {{#if}} untouched) via the shared trueserve_logic.when_directives renderer; async-agent's llm_agent node runs the same function in its workflow dialect. Mid-call, the InstructionRefresher re-renders on variable changes: identity-swap of the system message in the persistent LLMContext (cascaded/chat/Nova next-turn), plus a live session.update on OpenAI Realtime.
  • Tool gates (tool_gates.py) wrap the registered handlers OUTSIDE the input-healing wrapper (a veto must not be "healed"): available_when fails CLOSED and returns unavailable_message as the tool RESULT; bind params are stripped from the advertised schema in wiring phase 1 and injected at call time (bound values beat model-supplied); capture projects results into variables and fires tool_result rules. Result properties kwargs forward untouched. Use merge_bind instead of bind for an object that the model must supplement, such as prompt-extracted end_session.params. It accepts the same literal/variable sources and waits for pending extraction. The object stays in the advertised schema; application keys override model keys in a shallow merge. Non-object values refuse the tool call. A parameter cannot appear in both binding modes. For example, "merge_bind":{"params":{"kind":"variable","name":"transfer_params"}} preserves verified identifiers while allowing a new EscalationReason. For reliable extraction, advertise named string fields with builtin_tool_configs.end_session.params_fields, a field-name-to-description map. Those fields remain optional; the prompt controls which apply to a particular ending. An entry does not create a workflow destination.
  • Rules run in the LogicRulesProcessor (input chain, linear pipelines only — same limitation as injection; publish warns): session_start on StartFrame, user_message/turn_start on the final user transcript, turn_end relayed by the publisher (idle-handler pattern), variable_changed from the store, tool_result from the gate hook. Actions: set_variable, run_tool (invokes the REGISTERED — gated, healed — handler), inject_instruction (append frame; S2SUserAggregator forwards to S2S providers), end_session, escalate (via the registered consult/transfer tool), transition (topics).
  • Topics (topic_manager.py, per-stage config.topics): the current topic IS the current_topic session variable, so #when sections and variable_changed rules compose with no extra plumbing — and setting it is what triggers the refresher's PROMPT TAKEOVER swap. Model-initiated transitions return the resolved takeover instructions in the transition_topic result, without queuing a second injection that can race the provider's reply. Rule-initiated transitions deliver one system append, worded as a replacement. The advertised schema is built by build_transition_tool_schema in the actual phase-1 builder, including authored routing descriptions and __return__. A separate older builder schema omitted that destination: the callback accepted returns, but Nova could only select another specialist. Tests pin both the schema contents and the builder's use of the shared factory. Immediate instruction delivery must never silently fail: the append rides the LogicRulesProcessor, which is armed for topics-ONLY stages too (pipeline_has_topics — a stage with topics but no rules used to lose the injection AND return_when while transition_topic answered ok, so on Nova the "subagent" ran under the old prompt until the ~6-min rotation); when no frame path exists at all (parallel pipelines lose the input chain), the takeover text is embedded in the transition tool RESULT (instructions), which every provider reads in the same turn. A referral from Main records its empty topic as a real return destination; both __return__ and return_when can restore Main without treating it as an unknown topic. Returns also deliver an immediate instruction to resume Main's resolved stage prompt or the parent's resolved addendum, preserving existing answers and completed work. A model return uses its tool result; an automatic return uses one injection. Both pop history without rechecking the parent's entry gate. Main's prompt is captured after template resolution on the owning service, before startup greeting instructions are added. It excludes caller messages; known session variables are rendered again on return. Merely changing the variable left Nova using the specialist's previous instructions until its next session rotation. The delivered text is RESOLVED, never raw: PromptResolver.resolve token-resolves each topic's addendum into a _resolved_addendum sidecar on the shared topic dicts (authored text untouched; both the TopicManager and the refresher's _active_template prefer it — chevron would render a raw {{TOOL:…}} to EMPTY), and transition_to renders #when + known live variables into the entry text (_render_entry_text — unknown tags and program statements stay literal). Before this, a topic's {{COMPONENT:…}} render guidance never existed anywhere and {{customer_auth_status}} reached the model as a literal token, so it re-authenticated a verified caller on its second topic. Transitions are deterministic (rule transition actions) or model-chosen via the transition_topic tool, whose schema ENUMERATES the authored topic ids plus the __return__ sentinel (never a free-text id the model must invent — the consult_agent_id lesson); it registers AFTER the MCP block so MCP can't claim the name (the activate_skill pattern). entry_when is a GECX blocking rule (fail closed); return_when is the backward handoff, evaluated at each agent turn end (a return is a pop, not a forward hop — no bounce loops). Per-topic control picks the verb: refer (default) pushes the return history and injects hand-back guidance; transfer CLEARS it (full handover — __return__ then answers "nothing to return to"). An agent that assigns routing to Main can give an appointment specialist entry_when: current_topic == None or current_topic == "" or current_topic == "appointments". Apply the equivalent rule to its other specialists. A direct switch between specialists is then blocked; a legitimate new caller request returns through Main before entering its destination. Existing answers and verification remain in the session. Describe this routing policy in the specialists' instructions so the model knows how to recover from a blocked transition. This is an authored policy, not a restriction on every agent's topic graph.
  • Capability-published variables — a capability natively maintains its variables: Customer Authentication publishes customer_auth_status into the store on EVERY auth tool result (publish_auth_status_variable in customer_auth_tool_helper.py — call-shaped statuses like consent_required are deliberately not auth states), and the TopicManager maintains current_topic. The editor-side registry is CAPABILITY_MANAGED_VARIABLES + collectPlatformManagedVariables in node-builtins.ts — every entry is a CLAIM the runtime must honour (before this, the name was promised in hints/validation/linked_to examples while nothing wrote it). Known lag: the out-of-band browser-ceremony injection carries no status field, so the variable converges on the model's next auth tool call.
  • A2UI action capture — the surface's counterpart of tool capture: config.multimodality.action_capture ({action?, path, variable}) is stamped onto runtime_context["_a2ui_action_capture"] at build and applied by apply_ui_action_captures in the publisher on BOTH UI-action paths (gRPC + Redis — the recurring two-path trap), against the SANITIZED payload as {action, surface, context, data}; None skips rather than clobbers. Writes go through the store, so a form submit can flip the variables that re-render #when sections and open tool gates.
  • Response guards — two processors picked by topology in post_llm.maybe_insert_response_guards:
Topology Processor Enforcement
Cascade / chat / half-cascade (S2S stage with output_audio: false + downstream TTS) ResponseGuardProcessor FULL pre-utterance: sentence-buffered text checks before TTS/publisher; frame types preserved; violations replace / redact / regenerate / suppress
Native-voice S2S (Nova Sonic, Realtime voice-on) S2SResponseAuditGate audit (default): transcript checked as it streams, violation → flush queued audio + interrupt + corrective instruction. hold: audio buffered until the first sentence passes (bounded buffer, fail-open on overflow)

The critical-business-rules sweet spot is half-cascade: native-quality voice via a TTS stage plus full pre-utterance enforcement. Publish and preflight (GATE_LOGIC arm in preflight.py) both surface this suggestion on native-voice S2S stages with #when/guards. The decimal-safe sentence splitter is load-bearing: a . between digits must not split 4.5% ahead of the check.

on_violation: "suppress" (the editor's Keep silent action) discards the matching sentence and remaining response without a replacement, model retry, tool cancellation or call termination. The next model turn is handled normally. For example, an anchored pattern ^\s*VOICE_ONLY[.!]?\s*$ can consume an authored waiting-turn control token before TTS. Split token fragments remain buffered until the sentence or response ends. Native S2S suppression flushes held and queued audio without requesting a correction; it cannot recall audio already played in audit mode. Use text before TTS when silence must be enforced before the caller hears anything.

Authoring (ai-manager)

  • Prompt editor: #when directives render as clickable amber pills (IF/ELSE IF/ELSE/END IF) with nesting stripes (when-blocks.ts — text stays the single source of truth; selection inside a directive shows raw text for hand-editing). The flow is builder-first: picking the {{ autocomplete's If/Else skeleton opens the no-code condition builder (WhenConditionBuilder — GECX-style variable AND/OR rows, expression tab for power users, live validation via the TS mirror) IMMEDIATELY, and saving lands the caret in the branch body. An unset condition pill reads set condition…, a per-block + ELSE IF widget inserts {{:else when }} and opens the builder on it (the skeleton, pill clicks, and add-branch all funnel through ONE tsf-when-edit event), and empty branch bodies show a placeholder hint — a WIDGET, never document text, because placeholder prose in the document would ship to the model.
  • The @/{{ dropdown is HIERARCHICAL — an empty query shows category rows (Variable › / Tool › / Run an action › / Set a variable › / If-Else / Subagent › / Component ›); picking one swaps the SAME CM popup to that level with a ← Back row (menuLevelField in prompt-tokens.ts, anchored to the trigger position: replacing the trigger clears the level, typing a filter query keeps it), never a modal. TYPING a query bypasses the hierarchy — flat cross-category filtering, the power path. Level-2 picks are intent-aware: a variable under "Set a variable ›" opens the Set dialog preselected; a tool under "Run an action ›" opens the Run dialog preselected; "+ Add a tool…" opens the ToolLibraryDialog (searches /api/tools/references, adds the pick to config.tools, then inserts the mention/run — mention implies selection); "+ New variable…" declares via the shared NewVariableDialog and inserts. Both triggers share ONE options builder, so they can never drift.
  • The Actions strip (PromptActionsStrip) renders the stage's callable tools as chips in the editor's footer: the funnel opens the condition builder writing config.tool_logic[name].available_when (pure UI over the runtime's fail-closed gate — Agent Script's "available when", generalized from capabilities to every tool), ✕ removes the config.tools ref, and + Add opens the library dialog select-only. Capability-managed tools are FILTERED out (isCapabilityBuiltInRef — a capability adds several runtime names the author never picked; those are managed and gated on the capability card), while ordinary built-ins like End Session stay. The library dialog also CREATES inline Python / API / Knowledge tools (the old ToolSelector flows, reused: commit-on-Save only, canonicalized, definition into config.inline_tools + inline:<id> selected). Day-to-day tool add/remove/gating lives here; the deep ToolSelector (operation filters, overrides) moved under the Behavior tab's Advanced collapsible.
  • Catalog DATA TOOLS are HIDDEN from authoring (2026-08-30) — the wiring plumbing exists but stays dormant. A data tool (a2ui_tools row, served as an MCP tool by agent-host at /{catalog-slug}/mcp) calls no real backend today: fetch_data kinds echo the model's own inputs back as JSON, the render kinds draw a component from model-provided inputs (the same data path as a plain mention), and the data_tool_ref column that would link one to a real tool has no runtime consumer. Components are therefore self-sufficient — mention + render + action_capture answers — and surfacing a second "data tool" vocabulary with a manual wire-up step only confused authors. The ONE hiding switch is buildComponentCatalogs (data-sources.ts): it stops filling the per-catalog tools buckets, which together hides the dropdown's "Data Tools" section, the Add-tool dialog's data-tools section, and the unwired-mention warning. Dormant but working, for when data tools get real backends: the wiring predicate + filter-merge rules in data-tool-wiring.ts (newly-attached connection narrows to the picked op; attached with NO filter stays absent — absent = everything; existing filter appends), the find-or-create POST /api/tools/catalog-mcp (matched on stored server_url, reuses platform-seeded rows), the non-blocking publish check collectDataToolWiringWarnings, and the runtime's data-tool {{COMPONENT:…}} expansion into direct-call guidance (_fetch_catalog_tools + _format_data_tool_guidance in token_resolver.py, phrased per kind; components win a name collision). When reintroducing, attach the catalog MCP AUTOMATICALLY at publish (the capability-expansion pattern) — the manual "Wire it up" flow was the part authors found unintuitive.
  • The Run dialog is schema-aware, and its derivation is COLUMN-FIRST — /api/tools/references rows carry input_params + output_params + per-operation operations (reference cache v7; client key prompt-editor:tools:v4). Sources in order: the input_schema / output_schema / parameters COLUMNS on tool_configs (the canonical storage for python/API/workflow tools — the config copies are fallbacks; reading only config was the bug that showed no inputs on agent 3032's workflow tool), then for MCP rows the discovered operations (config.cached_tool_schemas, else tool_health.live_schema.tools — live entries use camelCase inputSchema), and for WORKFLOW tools the config.output_mapping[].name entries (each projects a workflow output into the tool result — they ARE the result keys). A multi-operation MCP row's RUNNABLE units are the operation names (the runtime registers each operation, never the row name — running the row name matches no handler): runnableToolItems expands them for the Run drill-in level and the Run dialog's picker, while {{TOOL:}} mentions keep the row. BUILT-IN tools (payments, customer auth, consult, end_session, send_dtmf) get inputs from BUILTIN_TOOL_INPUTS and outputs from BUILTIN_TOOL_OUTPUTS in node-builtins.ts — both are claims pinned by the AST drift guard test_builtin_tool_inputs.py: inputs against the Python FunctionSchemas, outputs against the *_TOOL_OUTPUTS contracts declared beside them in the tool helpers (which document conversation-api's to_tool_result shapes — e.g. collect_payment → status/transaction_id/amount_minor/currency/ method_type). The dialog seeds one empty row per declared input (a blank row is simply not sent), offers known outputs as one-click $.<name> capture suggestions, and custom rows stay addable. Empty outputs are fine — some tools return nothing.
  • Response-guard judge model is authorable — the guard editor's "Judge model" field writes check.model, which evaluate_llm_judge already reads (default gpt-4o-mini, currently resolved via OPENAI_API_KEY only — tenant-integration resolution is a follow-up).
  • Every pill is configurable — clicking opens its intent-appropriate editor. A {{TOOL:…}} pill (and the Actions strip's funnel) opens ToolLogicDialog — THE editing surface for config.tool_logic[name]: available_when, unavailable_message, progress_message (one scripted customer-facing sentence pushed immediately before an allowed callback is awaited), bind (fixed/variable-bound inputs the model never fills, with the declared parameter names as suggestions) and capture (result → variable, with the known-output chips) — plus Convert to Run action, which swaps the mention for a {{run …}} statement and opens the Run dialog (mention ≠ run stays the rule; conversion is the one-click upgrade). The dialog states that these settings are per STAGE per TOOL (one registered tool per stage — per-PLACE variation is what Run instances are for; per-context behaviour for model-invoked calls = bind a parameter to a variable and set it per subagent/rule). A {{COMPONENT:…}} pill opens ComponentMappingDialog — the multimodality.action_capture rows (answers → variables, $.data.… paths, optional per-action scoping) with predictable chips when the component declares its emissions: a2ui_components.emits (migration 047, authored per component in the catalog manifest as [{action, description?, fields:[{path, description?}]}], carried by catalog-builder's publish upsert and the /api/catalogs/components endpoint at cache v7) flattens into one-click (action, path) rows — the tool-outputs counterpart. Every shared catalog's components are DECLARED (56 emit entries across 34 components, derived from each widget's actual handleAction calls: context keys → $.context.<key> paths, action names from the catalog's own sample_props); existing DB rows — the TrueServe originals and every tenant clone — are backfilled by backfill-a2ui-emits.py (idempotent, only touches rows still carrying the empty default — run it on any environment that predates the declarations). Components without a declaration fall back to derived chips from *Action sample names, then free-form rows. The dialog also PINS PROPS: multimodality.component_props ({Component: {prop: {kind: literal|variable}}}) forces authored values over the model's choices at render time — apply_component_prop_overrides in a2ui_tool.py runs in BOTH A2UI execution paths (the recurring two-path trap; pinned by test_component_prop_overrides.py), resolves variable bindings LIVE against the shared session-variables dict at each render, never clobbers a model value with a sparse (None) variable, and never touches structural keys (id/component/child/children — publish validation refuses them too). Subagent pills open their tab; RUN/SET/IF pills their dialogs.
  • Every simple token is a pill too — token-pills.ts renders {{variable}}/{{TOOL:}}/{{SUBAGENT:}}/{{COMPONENT:}} (and workflow/skill/catalog refs) as colored chips with NO visible braces (sky/blue/violet/teal — the Agent Script look), same rules as the other pill extensions: text is the source of truth, atomic ranges, strict-interior overlap (select across a chip to hand-edit). A subagent chip click still dispatches SUBAGENT_OPEN_EVENT (the constants moved to token-pills.ts; prompt-tokens re-exports them). The @ and {{ dropdowns offer the SAME list including the Logic section (buildLogicCompletions — one builder, so the two triggers can't drift), and cancelling a dialog opened for a freshly inserted skeleton REMOVES the placeholder (inserted range on the edit events) while cancelling a pill-click edit changes nothing.
  • Condition builder values are typed — each row's right-hand side picks a kind (Text / Number / Variable — including platform names like currentDateTime — / True / False / Blank / Expression), and the is blank / is not blank operators need no value at all: they serialize to name in [None, ""] (an unset session variable reads as None, so "blank" covers never-set and explicitly-empty with one grammar-native expression both evaluators already handle). classifyValue round-trips a stored expression back into the kind picker; anything unrecognized opens as a raw Expression, untouched.
  • Prompt-program statements render as pills too (program-statements.ts, same rules as when-blocks: text is the source of truth, selection overlap reveals raw text): emerald RUN (tool + captured variables), sky SET (var = expr), a compact indigo = pill for interpolations (clicking one places the caret for inline editing). RUN/SET pill clicks and the {{ autocomplete's Run an action / Set a variable entries all funnel through ONE tsf-program-edit event into RunStatementDialog (tool picker over the stage's callable names, inputs table with quote-a-bare-word convenience, outputs table with + New variable…) and SetStatementDialog; both serialize through the TS mirror's serializeRunStatement / serializeSetStatement so stored text always matches the executed grammar.
  • Variables: declaration is optional — referencing {{name}} in any prompt (including inside a #when condition) brings the name into scope, sparse (None) until something sets it. The canvas Variables panel (flow/AgentVariablesPanel.tsx, in the inspector’s Behavior tab) lists everything in scope — declared, referenced-only, lifecycle workflow outputs, phone vars — and that same union feeds prompt autocomplete + every condition builder (useAgentPromptSources.lifecycleVariables + collectPromptVariableReferences). Typed declarations (config.variables: defaults, linked aliases, publish-time type checks) are created where the need arises — the cart-and-horse fix: every condition builder's variable picker and the rule pickers offer + New variable…, and the canvas panel has Add variable, all opening the shared NewVariableDialog (names normalize to snake_case — isAuthenticated → is_authenticated via normalizeVariableName; description is mandatory because publish blocks a declared variable without one). The write is an onDeclareVariable callback threaded up to the agent PAGE's handleConfigChange — variables is an agent-level key on the collab misc map, so a stage-scoped onChange cannot reach it. There is deliberately no Settings-dialog editor — variables are ambient authoring state, not an agent-level setting.
  • Subagents (the unified concept, prompt-first): "topic" and "subagent" are ONE authoring concept — a named specialist the conversation can route to — authored through the TAB STRIP above the prompt editor (SubagentTabs.tsx: Main | Returns | Billing | +, rendered inside SystemPromptSection). Selecting a tab swaps the editor to that subagent's instructions with a compact meta bar (name, when-to-use, control mode, conditions) — authoring never leaves the prompt (the old SubagentsSection card list is gone), and the subagent editor is the SAME full-featured PromptTemplateEditor the main prompt uses (copy-full-prompt header rendering the takeover prompt, actions strip + injected chips footer — tools/capabilities are stage-level, so that chrome is truthful for any active subagent). Semantics are PROMPT TAKEOVER (2026-08-29), not addendum: entering a subagent REPLACES the system prompt with its instructions — conversation history, memory, and session variables carry over untouched. The InstructionRefresher is the swap mechanism (_active_template() picks the active topic's text; the refresher is now armed whenever a stage has topics, not only when the main prompt carries directives), so takeover rides the SAME per-provider construction matrix as prompt programs — per-turn on cascade/chat, session.update on Realtime, a change-triggered continuation on Nova (the entry injection is the immediate in-band bridge there, worded as a replacement). Subagent prompts get the full program pass: #when / {{run}} / {{set}} work inside them, and the appended capability suffix (payments rules, consult prompts) is restored onto whichever prompt is active. A topic with NO authored instructions falls back to the main prompt — takeover never blanks the system message. Control moves one of TWO ways (the Agent Script transfer/refer model; subagents share the caller's model — user decision 2026-08-29): Refer (default) — control returns when the task completes: the runtime enumerates transition_topic("__return__"), entry guidance says to call it when done, and an optional return_when returns deterministically; Transfer — full handover: entering CLEARS the return history (topic_manager.py _topic_control + RETURN_TARGET), so a later __return__ can't bounce back into a conversation the transfer deliberately left (publish warns on a transfer with a dead return_when). Storage is UNCHANGED: config.topics.topics[] gains the optional control: 'refer'|'transfer' field; legacy config.subagents.agents[] defs (separate call / session takeover) still get tabs whose panel opens the full editor. The pure merge/slug helpers stay in lib/subagent-cards.ts. Card ids auto-derive as snake_case slugs of the name — they appear in expressions (current_topic == "returns") and the model-facing routing enum, so they must read as words. Each card's "when to use" is TopicConfig.description — it rides the transition_topic tool description (describe_for_schema), because the tool description is the only place the model learns when to move. Canvas nodes stopped rendering the subagent subtree: PipelineNode shows a view-only badge row (N subagents · M tools · K rules) instead — the node is topology, the prompt editor is behavior.
  • Prompt-as-hub: the main prompt's @/{{ dropdown lists every subagent flavor (buildSubagentPromptItems — matching the runtime's build_subagent_name_map, which resolves {{SUBAGENT:…}} for session-switch names, delegate tool_names, AND in-conversation entries → a transition_topic routing instruction). Typing an unmatched @name offers + New subagent (inserts the token + mints the card — SUBAGENT_CREATE_EVENT in prompt-tokens.ts), and clicking a {{SUBAGENT:…}} pill opens its card (SUBAGENT_OPEN_EVENT; the click deliberately still places the caret so hand-editing stays possible).
  • Rules & guards: the Behavior tab's Advanced collapsible (below Subagents, above Tools) nests LogicRulesSection (conversational stages only); collapsed by default, opens when the stage already has authored logic, counts on the trigger row. Nothing in it is free-text where config names a real thing: Route to a subagent (the stored action kind stays transition) picks from the in-conversation cards, and Run a tool / After a tool returns pick from the stage's LLM-callable names — buildAgentToolsList output plus separate-call subagent tool_names, which is what the runtime's registered-handler lookup matches on.
  • Per-tool logic (config.tool_logic) is fully supported by the schema, validation, editor, and runtime. A non-empty progress message also arms the input-chain logic processor on stages with no rules or topics. The message uses TTSSpeakFrame(append_to_context=False): it is real platform speech, not a model promise, and the normal tool result still drives the follow-up answer. Native S2S stages need the existing scripted-TTS lane (for example the Payments or Secure Data Capture handoff lane); cascaded voice uses its authored TTS.

Config shapes (authored, enforced from Phase 3 onward)

Declared in pipeline-types.ts (AgentVariableDeclaration, StageLogicConfig, ToolLogicConfig, LogicRule, ResponseGuard) and described field-by-field in the generated agent-config.schema.json (re-run generate-config-schemas.ts after changing them):

  • config.variables[] (agent-level): typed declarations (mutable w/ default, linked → alias of a runtime fact). Same namespace as runtime_context["session_variables"]. Top-level key — in MISC_ROOT_FIELDS in BOTH pipeline-shape.ts copies (ai-manager + collab-gateway) or collab autosave silently drops it (round-trip test pins it).
  • pipeline[N].config.logic: rules[] (trigger → optional condition → ordered actions; triggers session_start | user_message | turn_start | turn_end | tool_result | variable_changed) + response_guards[] (pre-utterance checks: pattern/code/llm; violations replace, redact, regenerate, or interrupt-correct; enforcement: hold|audit on native-voice S2S; strict = fail closed).
  • pipeline[N].config.tool_logic: per-tool available_when (fail CLOSED), unavailable_message (returned as the tool RESULT on veto), progress_message (spoken only after the gate passes, immediately before execution), bind (params stripped from the advertised schema), capture (result → variable projections). A sibling map keyed by resolved tool name — never a mutation of config.tools (overwritten mid-build with a ToolsSchema).
  • pipeline[N].config.multimodality.action_capture[]: A2UI answer → variable projections ({action?, path, variable}; paths over the sanitized {action, surface, context, data} payload). Authored from the relevant {{COMPONENT:…}} prompt pill in ComponentMappingDialog, so the editor shows a component's mappings in their actual prompt context rather than as an unrelated block in the Multimodal capability card.
  • Prompt directives in template.inline: {{#when expr}} … {{:else when expr}} … {{:else}} … {{/when}} (nestable; consumed before Chevron, so legacy Mustache {{#if}} keeps working), plus the prompt-program statements {{run …}} / {{set …}} / {{= …}} (consumed by the executor — see the prompt-programs section above).

Publish validation is agent-logic-validation.ts (pure; wired into validateAgentConfigForTenant + the generation MCP's validate_agent_config): errors block publish (unparseable expressions, writes to linked variables — including {{set}} targets and {{run}} captures — invalid guard regexes, unbalanced #when blocks, malformed prompt-program statements, output_path outside a tool_result trigger); warnings don't (references to undeclared names — session variables are sparse by design; {{run}}/{{set}} on a provider that only constructs at session start). Nova's changed instructions refresh automatically. The #when scanner is when-directives.ts.

See also

Required ending summaries

A stage can set builtin_tool_configs.end_session.require_summary: true. This makes params.summary required in the model-facing end-tool schema and rejects missing, blank or non-string summaries before the handler arms disconnection. The refusal asks the model to repair its arguments using the actual conversation. Provider callbacks, frame fallbacks and text-JSON fallback share the requirement; transport disconnects and runtime timeouts remain independent. The default is false. This is a presence/type contract, not a semantic judge: truthful content and the authored sentence requirement still need evaluation.

For referred topics with return_when, entry guidance explains the automatic return instead of requesting a second manual return. Avoid authoring a manual return after the same completion condition. Topics without that rule retain explicit __return__ guidance.