Skip to content

Commit 8965c7a

Browse files
authored
Merge pull request #166 from mhersson/feat/c2-agent-support
feat(agent-backend): cost breakdown, phase field, model pins, and UI
2 parents 86dfd05 + 268bab1 commit 8965c7a

42 files changed

Lines changed: 2766 additions & 273 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config.yaml.example

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,10 @@ task_skills:
200200
# the config block as an inert placeholder. Switch backends by toggling
201201
# enabled flags — do not delete blocks.
202202
#
203-
# TASK-ONLY FIELDS: orchestrator_sonnet_model, orchestrator_opus_model, and
204-
# reconcile_interval are valid on runner and agent entries. They are rejected
205-
# on the chat entry (validation error at startup).
203+
# TASK-ONLY FIELDS:
204+
# orchestrator_sonnet_model, orchestrator_opus_model, reconcile_interval —
205+
# runner entry only; rejected on agent and chat entries.
206+
# default_model — agent entry only; rejected on runner and chat entries.
206207
#
207208
# RESTART REQUIRED: backends are read once at startup. Any change requires
208209
# a CM restart.
@@ -214,9 +215,10 @@ task_skills:
214215
# CONTEXTMATRIX_BACKEND_<NAME>_URL
215216
# CONTEXTMATRIX_BACKEND_<NAME>_API_KEY
216217
# CONTEXTMATRIX_BACKEND_<NAME>_ENABLED
217-
# CONTEXTMATRIX_BACKEND_<NAME>_ORCHESTRATOR_SONNET_MODEL
218-
# CONTEXTMATRIX_BACKEND_<NAME>_ORCHESTRATOR_OPUS_MODEL
219-
# CONTEXTMATRIX_BACKEND_<NAME>_RECONCILE_INTERVAL
218+
# CONTEXTMATRIX_BACKEND_<NAME>_ORCHESTRATOR_SONNET_MODEL (runner only)
219+
# CONTEXTMATRIX_BACKEND_<NAME>_ORCHESTRATOR_OPUS_MODEL (runner only)
220+
# CONTEXTMATRIX_BACKEND_<NAME>_RECONCILE_INTERVAL (runner only)
221+
# CONTEXTMATRIX_BACKEND_<NAME>_DEFAULT_MODEL (agent only)
220222
backends: {}
221223
# runner:
222224
# # Base URL of the backend. Protocol paths are appended: /trigger, /kill,
@@ -248,19 +250,23 @@ backends: {}
248250
# agent:
249251
# # contextmatrix-agent: card execution only (no chat).
250252
# # Mutually exclusive with runner; use agent when runner is disabled.
251-
# #
252-
# # orchestrator_sonnet_model and orchestrator_opus_model for the agent
253-
# # backend must be OpenRouter slugs (e.g. "anthropic/claude-sonnet-4-6");
254-
# # the Claude-style defaults only apply to the runner backend. Unresolvable
255-
# # slugs fall back to the agent's own configured default model.
256-
# url: "http://localhost:9091"
253+
# url: "http://localhost:9092"
257254
# api_key: ""
258255
# enabled: false # flip to true and set runner: enabled: false to switch
259256
#
257+
# # Default orchestrator model for the agent backend — any OpenRouter slug.
258+
# # Per-card pins override it. The complexity selector picks coder/reviewer
259+
# # models per task; this default drives planning, review synthesis and docs.
260+
# # OPTIONAL: when unset, triggers carry an empty model and the agent service
261+
# # falls back to its own serve-config default. Resolution precedence is
262+
# # card pin → CM default_model → agent serve default.
263+
# # Env: CONTEXTMATRIX_BACKEND_AGENT_DEFAULT_MODEL
264+
# default_model: "deepseek/deepseek-v4-flash"
265+
#
260266
# chat:
261267
# # contextmatrix-chat: global chat only (no card execution). Project not
262268
# # yet released. Paired with agent when card execution is also needed.
263-
# url: "http://localhost:9092"
269+
# url: "http://localhost:9093"
264270
# api_key: ""
265271
# enabled: false
266272

docs/agent-workflow.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -626,9 +626,11 @@ token_costs:
626626
The `report_usage` call must pass `model` matching one of these keys. The model
627627
used depends on the orchestrator and phase — see the **Model Allocation**
628628
section below for the full breakdown. The `recalculate_costs` tool reprices
629-
cards that have non-zero tokens but zero stored cost (e.g. when usage was
630-
reported without a model name); it only touches qualifying cards and never
631-
overwrites a non-zero cost.
629+
from the current rate table: on cards with a usage breakdown every estimated
630+
bucket is re-priced (stale prices corrected) while actual provider-reported
631+
costs are never modified; on legacy cards without a breakdown it only fills in
632+
costs for cards with non-zero tokens but zero stored cost and never overwrites
633+
an existing cost.
632634

633635
## Model Allocation
634636

docs/api-reference.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,12 @@ Recalculate estimated costs for all cards in a project using current
778778
`token_costs` rates. Requires `default_model` for cards that have tokens but no
779779
model recorded.
780780

781+
Cards with a `usage_breakdown`: every bucket with `cost_source: estimated` is
782+
re-priced from the current rates (stale prices are corrected); buckets with
783+
`cost_source: actual` are never modified. Legacy cards without a breakdown:
784+
fill-missing-only — cards with non-zero tokens but $0 cost get a cost; cards
785+
with an existing cost are not modified.
786+
781787
```json
782788
{ "default_model": "claude-sonnet-4-6" }
783789
```

docs/data-model.md

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ type Card struct {
285285
Custom map[string]any `yaml:"custom,omitempty" json:"custom,omitempty"`
286286
Autonomous bool `yaml:"autonomous,omitempty" json:"autonomous"`
287287
UseOpusOrchestrator bool `yaml:"use_opus_orchestrator,omitempty" json:"use_opus_orchestrator,omitempty"`
288+
ModelOrchestrator string `yaml:"model_orchestrator,omitempty" json:"model_orchestrator,omitempty"`
289+
ModelCoder string `yaml:"model_coder,omitempty" json:"model_coder,omitempty"`
290+
ModelReviewer string `yaml:"model_reviewer,omitempty" json:"model_reviewer,omitempty"`
288291
Vetted bool `yaml:"vetted,omitempty" json:"vetted"`
289292
FeatureBranch bool `yaml:"feature_branch,omitempty" json:"feature_branch,omitempty"`
290293
CreatePR bool `yaml:"create_pr,omitempty" json:"create_pr,omitempty"`
@@ -293,7 +296,9 @@ type Card struct {
293296
PRUrl string `yaml:"pr_url,omitempty" json:"pr_url,omitempty"`
294297
ReviewAttempts int `yaml:"review_attempts,omitempty" json:"review_attempts,omitempty"`
295298
RunnerStatus string `yaml:"runner_status,omitempty" json:"runner_status,omitempty"`
299+
Phase string `yaml:"phase,omitempty" json:"phase,omitempty"`
296300
TokenUsage *TokenUsage `yaml:"token_usage,omitempty" json:"token_usage,omitempty"`
301+
UsageBreakdown []UsageBucket `yaml:"usage_breakdown,omitempty" json:"usage_breakdown,omitempty"`
297302
Created time.Time `yaml:"created" json:"created"`
298303
Updated time.Time `yaml:"updated" json:"updated"`
299304
ActivityLog []ActivityEntry `yaml:"activity_log,omitempty" json:"activity_log,omitempty"`
@@ -328,6 +333,17 @@ type TokenUsage struct {
328333
CacheCreationTokens int64 `yaml:"cache_creation_tokens,omitempty" json:"cache_creation_tokens,omitempty"`
329334
EstimatedCostUSD float64 `yaml:"estimated_cost_usd" json:"estimated_cost_usd"`
330335
}
336+
337+
type UsageBucket struct {
338+
Agent string `yaml:"agent" json:"agent"`
339+
Model string `yaml:"model" json:"model"`
340+
PromptTokens int64 `yaml:"prompt_tokens" json:"prompt_tokens"`
341+
CompletionTokens int64 `yaml:"completion_tokens" json:"completion_tokens"`
342+
CacheReadTokens int64 `yaml:"cache_read_tokens,omitempty" json:"cache_read_tokens,omitempty"`
343+
CacheCreationTokens int64 `yaml:"cache_creation_tokens,omitempty" json:"cache_creation_tokens,omitempty"`
344+
CostUSD float64 `yaml:"cost_usd" json:"cost_usd"`
345+
CostSource string `yaml:"cost_source" json:"cost_source"`
346+
}
331347
```
332348

333349
`CacheReadTokens` and `CacheCreationTokens` are optional (`omitempty`); they are
@@ -351,6 +367,26 @@ and 1-hour cache-write tiers. Claude Code uses the 5-minute tier by default.
351367
Agents should pass the `cache_creation_input_tokens` field from Claude's
352368
stream-json `usage` frame directly — no tier distinction is required.
353369

370+
### Usage breakdown
371+
372+
`UsageBreakdown` holds one `UsageBucket` per `(agent, model)` pair, merging every
373+
`report_usage` call for that pair into a single row. It exists to attribute cost
374+
after a card is released (when `assigned_agent` is cleared) and across multiple
375+
agents or models on one card. Empty-agent buckets roll up to the dashboard's
376+
`unassigned` label.
377+
378+
`cost_source` is `actual` when the bucket's cost came from the provider (passed
379+
on `report_usage` as `actual_cost_usd`) or `estimated` when it was priced from
380+
the local rate table. **Actual is authoritative and is never re-priced**
381+
`RecalculateCosts` re-prices only `estimated` buckets from the current rate
382+
table and leaves `actual` buckets untouched. A bucket that has ever received an
383+
actual-cost report stays `actual`.
384+
385+
The cumulative `TokenUsage` (counters and `estimated_cost_usd`) is kept equal to
386+
the bucket sum for breakdown cards: each report increments both the matching
387+
bucket and the cumulative total. Legacy cards reported before this feature have
388+
no buckets; the dashboard falls back to `assigned_agent` for their agent rollup.
389+
354390
```go
355391
// internal/board/project.go
356392

@@ -406,17 +442,24 @@ generation.
406442
**Server-managed fields** (set by service layer, not by clients directly): `id`,
407443
`created`, `updated`, `assigned_agent`, `last_heartbeat`, `activity_log`,
408444
`runner_status`, `review_attempts`, `branch_name`, `token_usage`,
409-
`dependencies_met`.
445+
`usage_breakdown`, `dependencies_met`.
446+
447+
**Agent-managed field**`phase`: the agent-orchestrator's progress within a run
448+
(`plan` | `execute` | `review` | `integrate` | `done`), orthogonal to `state`.
449+
Enum-validated; the empty string clears it and means "not agent-driven". Settable
450+
via the `update_card` MCP tool and REST (PUT/PATCH).
410451

411452
**Human-only fields** (may only be set by agents whose `X-Agent-ID` starts with
412453
`human:`): `vetted`, `autonomous`, `use_opus_orchestrator`, `feature_branch`,
413-
`create_pr`, and `base_branch`. POST `/api/projects/{project}/cards`
454+
`create_pr`, the three model pins (`model_orchestrator`, `model_coder`,
455+
`model_reviewer`), and `base_branch`. POST `/api/projects/{project}/cards`
414456
(`createCardRequest`) and PUT `/api/projects/{project}/cards/{id}`
415-
(`updateCardRequest`) gate the first five fields; `base_branch` is **only
416-
exposed via PATCH** (`patchCardRequest`) — there is no `base_branch` field on
417-
the create or full-update request bodies, so the human-only check for it applies
418-
only on PATCH. Agents that attempt to set these fields receive 403
419-
`HUMAN_ONLY_FIELD`. The MCP `update_card` tool does not expose them.
457+
(`updateCardRequest`) gate the first five fields plus the model pins;
458+
`base_branch` is **only exposed via PATCH** (`patchCardRequest`) — there is no
459+
`base_branch` field on the create or full-update request bodies, so the
460+
human-only check for it applies only on PATCH. The model pins are gated on
461+
create, full-update, and PATCH. Agents that attempt to set any of these fields
462+
receive 403 `HUMAN_ONLY_FIELD`. The MCP `update_card` tool does not expose them.
420463

421464
## Reserved labels
422465

internal/api/agents.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package api
22

33
import (
44
"encoding/json"
5+
"errors"
6+
"io"
57
"net/http"
68
"strings"
79

@@ -35,7 +37,7 @@ func (h *agentHandlers) claimCard(w http.ResponseWriter, r *http.Request) {
3537
}
3638

3739
var req agentRequest
38-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
40+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
3941
writeError(w, http.StatusBadRequest, ErrCodeBadRequest, "invalid JSON body", sanitizeErrorDetails(err))
4042

4143
return
@@ -70,7 +72,7 @@ func (h *agentHandlers) releaseCard(w http.ResponseWriter, r *http.Request) {
7072
}
7173

7274
var req agentRequest
73-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
75+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
7476
writeError(w, http.StatusBadRequest, ErrCodeBadRequest, "invalid JSON body", sanitizeErrorDetails(err))
7577

7678
return
@@ -105,7 +107,7 @@ func (h *agentHandlers) heartbeatCard(w http.ResponseWriter, r *http.Request) {
105107
}
106108

107109
var req agentRequest
108-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
110+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
109111
writeError(w, http.StatusBadRequest, ErrCodeBadRequest, "invalid JSON body", sanitizeErrorDetails(err))
110112

111113
return

0 commit comments

Comments
 (0)