feat(roster-assistant): assistant prompt + Claude call with structured output #1223
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/agent-hooks!1223
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "code-lead/1214"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Closes #1214.
Implements
buildAndCallProposer(history, catalog)behind the seam #1212 left inproposer.ts. After this lands,POST /roster/assistant/proposereturns real model-generatedProposalEnvelopes instead of the502 proposer_not_configuredsentinel.proposer-prompt.ts, revision1) so the team can iterate without redeploying types.CatalogSnapshotwithcache_control: ephemeralon the catalog block so the prefix stays warm across refinements and across sessions that see the samecatalog_hash.strict: truetool-use againstemit_proposal, whoseinput_schemais theProposalEnvelopeJSON Schema from #1210 with API-side unsupported keywords (minLength,maxLength,minItems, …) stripped. Full-schema semantics re-validated server-side viavalidateEnvelopeShape.tool_useblocks followed bytool_resultacks so Claude's user/assistant alternation rule holds across refinement loops.unavailableflag, so dropping preserves the contract from #1210).session_id,parent_proposal_id,refinements_used,version, andcatalog_hash— the model can't spoof them.getProposerLatencyStats()returns{count, p50_ms, p95_ms}.bootstrap.tscallsregisterRosterAssistantProposer()after the YAML engine boot.Model + tier
Sonnet 4.6 (
claude-sonnet-4-6) per the issue directive ("Model tier: Sonnet (general design work)"). The proposer is a single-turn structured-output call — Opus's long-horizon reasoning wouldn't add value at the cost shape.Latency (P50 / P95)
Issue calls for "first proposal < 15s" with measured P50/P95 in the PR description. The unit suite exercises the proposer with a mocked client, so the recorded latencies don't reflect real Anthropic round-trips — synthetic latency in tests is 250 ms (P50/P95 both 250 ms). The
getProposerLatencyStats()helper surfaces live numbers once the route sees real traffic; a follow-up will expose it on/api/healthso the operator can read it without grepping logs. (The latency-target acceptance from the issue can be re-verified end-to-end once #1213's sidebar lands and the FE is wired to hit the endpoint against a real model.)Test coverage
21 tests in
proposer-impl.test.ts, all green:pattern,format,additionalProperties.renderCorrectiveNotelists them;dropHallucinatedEntriespreserves the rest.parent_proposal_idregardless of model emission; latency stats recorded.tool_useblock →RosterAssistantNoToolCallError; malformed envelope →RosterAssistantInvalidEnvelopeError; kebab-case violation →RosterAssistantInvalidEnvelopeError.registerRosterAssistantProposer()overwrites the seam.Test plan
just qa) — 3443 / 3443 pass.POST /roster/assistant/proposewithANTHROPIC_API_KEYset, confirm a non-emptyProposalEnvelopecomes back andusage.cache_read_input_tokens > 0on the second refinement.catalog_hashon the envelope.🤖 Generated with Claude Code
Review — feat(roster-assistant): assistant prompt + Claude call with structured output
CI ✅ green. The overall shape is excellent: the two-block caching layout, history replay via tool_use/tool_result alternation, schema-keyword stripping before the tool gate, server-side stamping, and the 21-test suite covering every failure leg are all well-executed. One real bug and a couple of minor issues need addressing before merge.
🔴 Blocking —
proposal_idfallback is dead code and the comment is factually wrongFile:
proposer-impl.ts, stamping block inbuildAndCallProposervalidateEnvelopeShaperuns before this block and assertsproposal_idis a non-empty string (requireString("proposal_id")). By the time the stamping runs,envelope.proposal_idis always truthy, so the|| prp_${randomUUID()}branch is unreachable dead code.More importantly, the comment says "the model can't reuse a prior one" — but it absolutely can. If the model returns the same
proposal_idas a prior proposal, nothing stops it. The PR description's own list of server-stamped fields (session_id,parent_proposal_id,refinements_used,version,catalog_hash) deliberately omitsproposal_id, which suggests trusting the model's value may be intentional — but then the comment is a lie and will mislead anyone reading it.Pick one of:
Option A — Always generate server-side (matches the comment's claim):
Option B — Trust model's value (matches the PR description's omission, but drop the dead fallback and misleading comment):
Option A is safer (the FE's parent-diff bookkeeping relies on stable, unique IDs and a model that replays a prior
proposal_idon a retry would corrupt the diff chain).🟡 Moderate —
versionandrefinements_usedare always stamped identicallyEvery test asserts both to the same value. If these two fields are intentionally synonymous (e.g. the FE uses one for display and the other for limit enforcement), that's fine — but the intent should be stated in a comment so future editors don't try to "fix" one without knowing they're the same. If
versionwas always meant to be a static schema-format number (likeROSTER_ASSISTANT_PROMPT_VERSION = "1"), this is a bug. Please clarify with a one-liner comment, or align with whateverProposalEnvelopedocuments in@claude-hooks/shared.🟢 Minor — bootstrap comment overstates the catalog ordering dependency
File:
bootstrap.tsregisterRosterAssistantProposer()only installs a closure;collectCatalog()is called lazily on the first request, not at registration time. The ordering is still correct practice (catalog reads should work before the first real request lands), but the comment implies the catalog is read at boot, which it isn't. Suggest: "…is ready before the first/roster/assistant/proposerequest arrives."🟢 Minor —
latencyRing.shift()is O(n), not a true ringThe ring buffer uses
push+shift. At 256 samples this is negligible, butshiftre-indexes the full array on every eviction. If the buffer size ever grows, consider a circular-index approach. No action needed for this PR — just noting it.🟢 Minor — percentile formula skews slightly high
Math.floor(n * p)gives an index one past the conventional median for even-length arrays. The standard formula isMath.floor((n - 1) * p). At 256 samples the difference is one slot — operationally irrelevant, but worth aligning if you intend to expose these numbers on/api/health.✅ What's solid
cache_control: ephemeralon the catalog block and the frozen prompt first — correct placement ensures the tool definition + prompt + catalog all land in one cache prefix.tool_use/tool_resultacks is exactly right; the synthetictoolu_${proposal_id}IDs are stable across replays for cache efficiency.strict: true+ server-sidevalidateEnvelopeShapedefense-in-depth is the right approach — tool gate handles structure, server validates semantics the API gate can't enforce.unavailablefield) is correctly documented.@anthropic-ai/sdkadded directly for a single-turn Messages call — the right call; spinning up an agent loop for one structured-output turn would be wasteful.bootstrap.tsis clean and correctly positioned after the YAML engine.Fix the
proposal_iddead-code / misleading comment and clarifyversion, then this is good to merge.- proposal_id is always server-generated (Option A from review). The prior `envelope.proposal_id || prp_${randomUUID()}` was dead code (validateEnvelopeShape already requires non-empty proposal_id) and the comment claiming the model couldn't reuse a prior id was a lie. Always-generate is safer for the FE's parent-diff bookkeeping. - Document that version and refinements_used collapse to the same arithmetic under current session-store semantics, with the collapse stamped from a single `stamp` expression so any future divergence (e.g. "first proposal doesn't count as a refinement") only needs to touch one branch. - bootstrap.ts: clarify that registerRosterAssistantProposer() only installs a closure; collectCatalog() runs lazily on first request. - Switch percentile formula to Math.floor((n - 1) * p) so /api/health consumers see conventionally-rounded values. 3443 / 3443 tests pass via `just qa`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Thanks for the thorough review. Pushed
e498533addressing every item:🔴 Blocking —
proposal_iddead codeTook Option A —
proposal_idis now always server-generated (prp_${randomUUID()}). Dropped the dead fallback and rewrote the comment to match. Reasoning matches yours: trusting the model's value risks corrupting the FE'sparent_proposal_iddiff chain on a regression / replay.🟡 Moderate —
versionvsrefinements_usedAdded a doc-block above the stamping that spells out the conceptual distinction (per
packages/shared/src/roster-proposal.ts) and notes that under current session-store semantics — every successful proposal bumpsrefinements_used, including the first — the two values collapse to the same arithmetic. Both are now stamped from a singlestamp = opts.refinements_used + 1expression so any future divergence (e.g. "first proposal doesn't count as a refinement") only needs to touch one branch.🟢 Minor — bootstrap comment
Rewrote to: "Registration only installs a closure —
collectCatalog()is called lazily on the first request, not here — so this only needs to run before the first/roster/assistant/proposerequest arrives."🟢 Minor — percentile formula
Switched to
Math.floor((n - 1) * p)with a comment explaining why. The 1-sample test path still lands on index 0 either way, so no test changes were needed.🟢 Minor —
latencyRing.shift()Left as-is per your "no action needed for this PR" — happy to swap to a circular index in a follow-up if the buffer ever grows.
Verification
just qaclean: 3443 / 3443 tests pass.proposer-impl.ts+bootstrap.ts), +35 / -11.Ready for re-review.
Review —
feat(roster-assistant): assistant prompt + Claude call with structured outputCI ✅ green. This is production-quality work. Approving with a few minor observations worth tracking.
Overview
Fills the
_rosterAssistantProposerseam from #1212 with a real Claude Messages API call. The design is correct end-to-end: two-block system layout with cache-breakpoint on the catalog, history replay that satisfies the alternation rule, strict structured-output with server-side re-validation, and a corrective-retry + drop-fallback chain for hallucinated catalog ids. All seven server-stamped fields (proposal_id,session_id,parent_proposal_id,version,refinements_used,created_at,catalog_hash) are overwritten after the model returns, so the model can't spoof any of them. 21 tests, full DI on client / catalog / clock, no network calls in the suite.Things done well
proposal_idis explicitly discarded and replaced withprp_${randomUUID()}. The comment explaining why (same-id replay would corrupt the FE's diff chain) is exactly the right level of explanation.cache_control: ephemeral. Correct placement means the breakpoint covers tool definition + prompt + catalog in one prefix, maximising cache reuse across refinements.tool_use+tool_resultack, then a user block with the violation list. That's the right wire format; a bare text message without the prior tool pairing would 400 from the API.stubClientqueuing responses,catalogfixture injection, andnowoverride give complete determinism without any module mocking. Clean pattern.historyToMessagesalternation-rule explanation in particular will save the next person a debug session.Minor observations
1.
collectCatalog()is called per-request — latency risk against the < 15 s targetEvery
/roster/assistant/proposecall queries the DB for the catalog. Thecache_control: ephemeralon the catalog block caches Anthropic's token processing of that text, but the DB round-trip happens regardless. If catalog assembly involves joins across skills / MCPs / tools tables and the catalog is large, this is the most likely source of latency exceeding the issue target in steady state. A short-lived in-memory TTL cache (e.g. 30 s, invalidated oncatalog_hashchange) would let the token cache actually deliver its full benefit. Not a blocker — the latency ring will surface the data under real traffic — but worth a follow-up issue.2.
versionandrefinements_usedshare a singlestampexpressionThe comment explains current session-store semantics make them identical. The risk is that a later PR changes how the session store counts refinements and silently makes the two fields diverge in meaning while the code still uses one expression. Two explicit variables — or a
// INVARIANT: keep these in sync until session-store counting changescomment that names the coupling — would make a future divergence harder to miss. Very minor.3.
isIsoDateTimeaccepts partial datesDate.parse("2024")is notNaN, so the backstop would accept a bare year. The schema'sformat: "date-time"is the real gate; this function is only a fallback forcreated_at, so the risk is limited to a weird-looking timestamp in the envelope rather than a contract break. Acceptable as-is — just noting it in case the function is ever reused for stricter validation elsewhere.Not an issue — confirming intentional choices
@anthropic-ai/sdkadded alongside@anthropic-ai/claude-agent-sdk: The agent SDK is for orchestration (spawn / run containers); the SDK is for direct Messages API calls. Different use-cases, separate packages. Correct.console.warnfor the drop-fallback warning: Matches codebase convention (infrastructure/billing.ts,container/lifecycle-metrics.ts, etc.). No structured logger in-process to route to.strict: trueonemit_proposal: Available in@anthropic-ai/sdk ^0.81.0. Combined withtool_choice: { type: "tool", name }this is the correct pairing for guaranteed structured output.Array.shift()in the latency ring: O(n) at n=256 is ~microseconds. Not a performance concern._resetProposerLatencyForTestexported from production code: Standard necessary trade-off for module-level ring buffer; no cleaner alternative without a test-only module boundary.Verdict
Approve. The implementation is correct, the edge cases are handled, and the test coverage is exemplary. The two observations above (catalog caching latency,
stampexpression coupling) are worth follow-up issues but don't block this landing.