Skip to content

refactor(voice): step 5a-1 — behavior anchors + tuned-prompt factory extraction (LiveAgentRuntime slice 1) - #1964

Merged
qingyun-wu merged 14 commits into
mainfrom
feat/step5-behavior-anchors
Jul 7, 2026
Merged

qingyun-wu merged 14 commits into
mainfrom
feat/step5-behavior-anchors

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

First slice of step 5 (LiveAgentRuntime extraction), kept deliberately small and verifiable:

Commit 1 — behavior anchors: 126-tool snapshot fixture (names/descriptions/execution/param surface — the tuned strings Gemini sees) + tripwires, established BEFORE moving anything.

Commit 2 — the move: greeting + system-instruction factories extracted verbatim from voice-agent.ts (unimportable: runs main() at load) into importable src/voice-agent-config.ts. State threads in via VoiceConfigContext; env reads run verbatim with test-only overrides.

Equivalence evidence (the whole point of anchors-first):

  • all 87 tuned string entries identical in order (programmatic old-vs-new comparison)
  • anchors upgraded to REAL factory output: fixed-env instruction hash, exact meeting-greeting, conditional variants (meeting ⚠️ rule swap, googleSearch omission, mode-marker-first placement), reconnect replay-guard shape
  • 10/10 anchor tests green across three environments (incl. empty CLAUDE_CONFIG_DIR — CI-safe); tsc + task-bridge suites green

Phone (conversation-server) untouched — that's 5b. Next slice (5a-2): LiveAgentRuntime skeleton + WebVoiceLiveAdapter, now verifiable against these anchors. Live smoke call with the owner before 5a lands as behavior-effective.

— @sutando-qingyun-001

qingyun-wu and others added 10 commits July 6, 2026 15:59
…(interaction-planes step 1)

A task producer is a (source, interaction_type) pair, not a source alone.
This stamps the pair at every write site — purely additive, old readers
ignore the extra line, no consumer behavior changes:

- message: slack/telegram/discord bridges, twilio SMS + voicemail, chat template
- realtime_audio: voice work-tool (task-bridge.ts), phone work-tool (delegateTask)
- tool_initiated: writeChatTask (Gemini inline tool), agent-api /task (agent-to-agent)
- system_event: health-check --emit-task, context-drop (app + task-bridge),
  github-webhook, twilio incoming-call, phone call-end summary
- remote-gateway-bridge: passes through the gateway's interaction_type when
  present, defaults to message (all current gateway traffic is Matrix room
  messages)

tests/interaction-type-header.test.py sweeps every producer write site and
fails if a source: serialization lacks the paired interaction_type within
the write window — so new producers can't silently ship without it.

First step of the interaction-planes refactor (snapshot: v0.5.0-pre-interaction-planes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…delegateTask

Round-2 improvements on the same PR:

- remote-gateway-bridge: whitelist the pass-through against the six-value
  interaction-plane vocabulary — the gateway is outside the trust boundary,
  so an unknown remote value degrades to "message" instead of landing
  verbatim in the local task file.
- conversation-server delegateTask: add the missing source: phone so the
  producer carries the full (source, interaction_type) pair. This activates
  two consumers that always listed "phone" but never matched because the
  writer predates the source header: task_priority phone→urgent, and
  discord-bridge DM_FALLBACK_SOURCES (a result landing after the call ended
  now DMs the owner instead of rotting unclaimed).
- call-end summary writer deliberately does NOT get source: phone — its task
  instructions already have the core DM the owner, so the bridge fallback
  would deliver the same summary twice. Comment at the writer + a serialized-
  form-only test assertion guard the double-DM regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #1933 coverage gate (first PR through it) flagged the new
remote-gateway-bridge lines as uncovered — the sweep test greps write
sites but never executes them. This exercises _write_task directly:
whitelisted pass-through, unknown-value degradation to message (trust
boundary), absent-field default, full vocabulary round-trip, and the
access_tier-last ordering guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tests, reader switch-over (step 3) (#1954)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…gion tripwires

Pins the voice agent's tuned behavior surface before the LiveAgentRuntime
extraction moves anything: 126 importable tool definitions (name,
description, execution mode, parameter keys + per-key descriptions)
snapshotted to a committed fixture, plus SHA-256 tripwires on the
non-importable tuned regions of voice-agent.ts (instructions factory,
greeting/reconnect factory, tool-table composition — the module runs
main() at load, so tests cannot import it). Every step-5 slice must keep
these green or deliberately regenerate with the diff called out.

Verified environment-independent: identical anchors with an empty
CLAUDE_CONFIG_DIR (manifest tools resolve from the repo tree).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ig.ts (step 5a-1)

PURE MOVE of the greeting factory and system-instruction factory out of
voice-agent.ts (which runs main() at module load and is therefore
untestable) into an importable module. voice-agent.ts keeps its state
(mode resolver, meeting flag, session gates) and threads it in through
VoiceConfigContext; env-dependent reads (stand identity, voice context,
repo URL, agent context) run verbatim by default with test-only overrides.

Equivalence evidence:
- all 87 tuned string-array entries identical IN ORDER between the old
  region and the new module (programmatic comparison)
- template-literal set identical except the intentional override branch
- behavior anchors upgraded from source-hash tripwires to REAL factory
  output: fixed-env instruction hash, exact meeting-greeting string,
  conditional-variant assertions (meeting/googleSearch/mode-marker
  placement), reconnect replay-guard shape — 10/10 green across normal,
  repeat, and empty-CLAUDE_CONFIG_DIR runs

tsc clean; task-bridge suites green. Phone (conversation-server) untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold review (Rui Bot):

LGTM — clean verbatim move.

Checked:

  • Reset order: ctx.resetNoteViewingDebounce() (line 131) precedes ctx.resetSessionGates() (line 138) in buildGreeting — matches original voice-agent.ts order ✓
  • googleSearch capture: VOICE_GOOGLE_SEARCH was already a module-level constant (voice-agent.ts:206) — capturing it as googleSearch: VOICE_GOOGLE_SEARCH in _configCtx is correct; no per-call evaluation was ever happening ✓
  • Anchor upgrade: fixture-based assertions on real factory output are a meaningful improvement over the pre-5a-1 source-hash tripwires; deterministic overrides for env-dependent segments are the right approach ✓
  • CLAUDE.md compliance: no prompt string changes, only importability refactor ✓

Nit: the _configCtx object is module-level (defined once). The isMeetingActive: () => meetingActive closure is correct (reads live module state). No issues found.

qingyun-wu and others added 3 commits July 6, 2026 18:34
First CI run of the anchors showed the tool table is machine-specific
after all: manifest-loaded skill tools (gws-gmail read_email,
report_feedback, screenshot_explain, …) load on installed hosts but not
on CI — the empty-CLAUDE_CONFIG_DIR simulation missed them because the
manifest scan also covers other paths. inline-tools now exports
envDependentToolNames (manifest tools + presenter-sentinel conditionals)
and the anchor pins the 80-tool STATIC surface, which is identical
everywhere. Excluding per-env extras is sound: a tool absent from an
env's exclusion set is also absent from that env's tool list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prompt deliberately embeds the installed tool list, so a raw hash of
buildInstructions output can never be env-stable (CI lacks the manifest
tools). The anchor now hashes the instructions with the two dynamic
segments masked (the joined tool-names line and per-tool '. Instant.'
lines) — pinning all tuned prose exactly — with a separate well-formedness
assertion that the dynamic lines exist. 11/11 in both local and
empty-config runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI green across all checks. Upgrading from comment to approval — LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants