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:
andornot(short-circuit;and/orreturn operand values, the gate wrappers apply truthiness) - comparisons:
==!=<<=>>=innot in, chained (0 < score <= 100) - literals: strings (
'…'/"…"), numbers,TrueFalseNone, 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"isFalse, 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") isFalse, never an error — Python's per-operatorTypeErrorcatch x in <string>is a substring test and requires a string left operand;inon a non-container is an evaluation error (policy applies)True == 1is 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-Falsecatch - 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 sametool_invoker.pypath the rules engine'srun_tooluses, 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):
SessionVariableStorewraps the SAMEruntime_context["session_variables"]dict every existing reader uses (handoff rules,$var:bindings, IVR goals, prompt variables). Seeded fromconfig.variablesat build (setdefault— an inbound CCaaS value beats a config default);linkedvariables are read-through aliases and refuse writes; change subscriptions drive everything below. Reads (get/snapshot, andlinkedtargets) FALL THROUGH toruntime_context["session_metadata"]for any name not insession_variables, so the canonical session facts (ani/dnis,conversation_id…) — which live in metadata and are never copied into variables — resolve for abindand for rule/#whenexpressions exactly as they already do for prompt{{ani}}(which readsbuild_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 tosession_variables— metadata is read-only session context.#whenprompt sections render inPromptResolver.resolvestep 2.6 (after the token pass, before Chevron — legacy{{#if}}untouched) via the sharedtrueserve_logic.when_directivesrenderer; async-agent'sllm_agentnode runs the same function in its workflow dialect. Mid-call, theInstructionRefresherre-renders on variable changes: identity-swap of the system message in the persistent LLMContext (cascaded/chat/Nova next-turn), plus a livesession.updateon OpenAI Realtime.- Tool gates (
tool_gates.py) wrap the registered handlers OUTSIDE the input-healing wrapper (a veto must not be "healed"):available_whenfails CLOSED and returnsunavailable_messageas the tool RESULT;bindparams are stripped from the advertised schema in wiring phase 1 and injected at call time (bound values beat model-supplied);captureprojects results into variables and firestool_resultrules. Resultpropertieskwargs forward untouched. Usemerge_bindinstead ofbindfor an object that the model must supplement, such as prompt-extractedend_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 newEscalationReason. For reliable extraction, advertise named string fields withbuiltin_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_starton StartFrame,user_message/turn_starton the final user transcript,turn_endrelayed by the publisher (idle-handler pattern),variable_changedfrom the store,tool_resultfrom 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-stageconfig.topics): the current topic IS thecurrent_topicsession variable, so#whensections andvariable_changedrules 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 thetransition_topicresult, 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 bybuild_transition_tool_schemain 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 theLogicRulesProcessor, which is armed for topics-ONLY stages too (pipeline_has_topics— a stage with topics but no rules used to lose the injection ANDreturn_whenwhiletransition_topicanswered 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__andreturn_whencan 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.resolvetoken-resolves each topic's addendum into a_resolved_addendumsidecar on the shared topic dicts (authored text untouched; both the TopicManager and the refresher's_active_templateprefer it — chevron would render a raw{{TOOL:…}}to EMPTY), andtransition_torenders#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 (ruletransitionactions) or model-chosen via thetransition_topictool, whose schema ENUMERATES the authored topic ids plus the__return__sentinel (never a free-text id the model must invent — theconsult_agent_idlesson); it registers AFTER the MCP block so MCP can't claim the name (theactivate_skillpattern).entry_whenis a GECX blocking rule (fail closed);return_whenis the backward handoff, evaluated at each agent turn end (a return is a pop, not a forward hop — no bounce loops). Per-topiccontrolpicks the verb:refer(default) pushes the return history and injects hand-back guidance;transferCLEARS it (full handover —__return__then answers "nothing to return to"). An agent that assigns routing to Main can give an appointment specialistentry_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_statusinto the store on EVERY auth tool result (publish_auth_status_variableincustomer_auth_tool_helper.py— call-shaped statuses likeconsent_requiredare deliberately not auth states), and the TopicManager maintainscurrent_topic. The editor-side registry isCAPABILITY_MANAGED_VARIABLES+collectPlatformManagedVariablesinnode-builtins.ts— every entry is a CLAIM the runtime must honour (before this, the name was promised in hints/validation/linked_toexamples 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 ontoruntime_context["_a2ui_action_capture"]at build and applied byapply_ui_action_capturesin the publisher on BOTH UI-action paths (gRPC + Redis — the recurring two-path trap), against the SANITIZED payload as{action, surface, context, data};Noneskips rather than clobbers. Writes go through the store, so a form submit can flip the variables that re-render#whensections 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:
#whendirectives 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 ONEtsf-when-editevent), 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 (menuLevelFieldinprompt-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 theToolLibraryDialog(searches/api/tools/references, adds the pick toconfig.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 writingconfig.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 theconfig.toolsref, 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 intoconfig.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_toolsrow, served as an MCP tool by agent-host at/{catalog-slug}/mcp) calls no real backend today:fetch_datakinds 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 thedata_tool_refcolumn that would link one to a real tool has no runtime consumer. Components are therefore self-sufficient — mention + render +action_captureanswers — and surfacing a second "data tool" vocabulary with a manual wire-up step only confused authors. The ONE hiding switch isbuildComponentCatalogs(data-sources.ts): it stops filling the per-catalogtoolsbuckets, 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 indata-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-createPOST /api/tools/catalog-mcp(matched on storedserver_url, reuses platform-seeded rows), the non-blocking publish checkcollectDataToolWiringWarnings, and the runtime's data-tool{{COMPONENT:…}}expansion into direct-call guidance (_fetch_catalog_tools+_format_data_tool_guidanceintoken_resolver.py, phrased perkind; 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/referencesrows carryinput_params+output_params+ per-operationoperations(reference cache v7; client keyprompt-editor:tools:v4). Sources in order: theinput_schema/output_schema/parametersCOLUMNS ontool_configs(the canonical storage for python/API/workflow tools — the config copies are fallbacks; reading onlyconfigwas the bug that showed no inputs on agent 3032's workflow tool), then for MCP rows the discovered operations (config.cached_tool_schemas, elsetool_health.live_schema.tools— live entries use camelCaseinputSchema), and for WORKFLOW tools theconfig.output_mapping[].nameentries (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):runnableToolItemsexpands 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 fromBUILTIN_TOOL_INPUTSand outputs fromBUILTIN_TOOL_OUTPUTSin node-builtins.ts — both are claims pinned by the AST drift guardtest_builtin_tool_inputs.py: inputs against the Python FunctionSchemas, outputs against the*_TOOL_OUTPUTScontracts declared beside them in the tool helpers (which document conversation-api'sto_tool_resultshapes — 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, whichevaluate_llm_judgealready reads (defaultgpt-4o-mini, currently resolved viaOPENAI_API_KEYonly — 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) opensToolLogicDialog— THE editing surface forconfig.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) andcapture(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 opensComponentMappingDialog— themultimodality.action_capturerows (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/componentsendpoint 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 actualhandleActioncalls:contextkeys →$.context.<key>paths, action names from the catalog's ownsample_props); existing DB rows — the TrueServe originals and every tenant clone — are backfilled bybackfill-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*Actionsample 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_overridesina2ui_tool.pyruns in BOTH A2UI execution paths (the recurring two-path trap; pinned bytest_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.tsrenders{{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 dispatchesSUBAGENT_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 (insertedrange 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 theis blank/is not blankoperators need no value at all: they serialize toname 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).classifyValueround-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 ONEtsf-program-editevent intoRunStatementDialog(tool picker over the stage's callable names, inputs table with quote-a-bare-word convenience, outputs table with + New variable…) andSetStatementDialog; both serialize through the TS mirror'sserializeRunStatement/serializeSetStatementso stored text always matches the executed grammar. - Variables: declaration is optional — referencing
{{name}}in any prompt (including inside a#whencondition) 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,linkedaliases, 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 sharedNewVariableDialog(names normalize to snake_case —isAuthenticated→is_authenticatedvianormalizeVariableName; description is mandatory because publish blocks a declared variable without one). The write is anonDeclareVariablecallback threaded up to the agent PAGE'shandleConfigChange—variablesis an agent-level key on the collab misc map, so a stage-scopedonChangecannot 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 insideSystemPromptSection). 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 oldSubagentsSectioncard list is gone), and the subagent editor is the SAME full-featuredPromptTemplateEditorthe 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. TheInstructionRefresheris 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.updateon 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 enumeratestransition_topic("__return__"), entry guidance says to call it when done, and an optionalreturn_whenreturns 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 deadreturn_when). Storage is UNCHANGED:config.topics.topics[]gains the optionalcontrol: 'refer'|'transfer'field; legacyconfig.subagents.agents[]defs (separate call / session takeover) still get tabs whose panel opens the full editor. The pure merge/slug helpers stay inlib/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" isTopicConfig.description— it rides thetransition_topictool 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:PipelineNodeshows 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'sbuild_subagent_name_map, which resolves{{SUBAGENT:…}}for session-switchnames, delegatetool_names, AND in-conversation entries → a transition_topic routing instruction). Typing an unmatched@nameoffers + New subagent (inserts the token + mints the card —SUBAGENT_CREATE_EVENTinprompt-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 staystransition) picks from the in-conversation cards, and Run a tool / After a tool returns pick from the stage's LLM-callable names —buildAgentToolsListoutput plus separate-call subagenttool_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 usesTTSSpeakFrame(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 (mutablew/ default,linked→ alias of a runtime fact). Same namespace asruntime_context["session_variables"]. Top-level key — inMISC_ROOT_FIELDSin 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; triggerssession_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|auditon native-voice S2S;strict= fail closed).pipeline[N].config.tool_logic: per-toolavailable_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 ofconfig.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 inComponentMappingDialog, 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¶
- Warm handoff — where handoff routing rules gate
start_consult - Universal pipeline builder
- Config JSONB contract
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.