Skip to content

Commit 92a9d32

Browse files
fix: persist sub-agent execution metrics
Fixes #84
1 parent adea3a4 commit 92a9d32

6 files changed

Lines changed: 183 additions & 19 deletions

File tree

‎event_store.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ def integer(value: int | None) -> int | None:
318318

319319
def list_usage_attempts(
320320
self, *, workspace_id: str | None = None, session_id: str | None = None,
321-
user_id: str | None = None, limit: int = 1000,
321+
user_id: str | None = None, run_id: str | None = None, limit: int = 1000,
322322
) -> list[dict[str, Any]]:
323323
clauses = ["1=1"]
324324
params: list[Any] = []
@@ -331,6 +331,9 @@ def list_usage_attempts(
331331
if user_id is not None:
332332
clauses.append("user_id=?")
333333
params.append(user_id)
334+
if run_id is not None:
335+
clauses.append("run_id=?")
336+
params.append(run_id)
334337
params.append(max(1, min(limit, 10000)))
335338
with self.connection() as db:
336339
rows = db.execute(
@@ -341,13 +344,13 @@ def list_usage_attempts(
341344

342345
def usage_totals(
343346
self, *, workspace_id: str | None = None, session_id: str | None = None,
344-
user_id: str | None = None, since: str | None = None,
347+
user_id: str | None = None, run_id: str | None = None, since: str | None = None,
345348
) -> dict[str, Any]:
346349
"""Aggregate immutable usage records for one explicit durable scope."""
347350
clauses = ["1=1"]
348351
params: list[Any] = []
349352
for field, value in (("workspace_id", workspace_id), ("session_id", session_id),
350-
("user_id", user_id)):
353+
("user_id", user_id), ("run_id", run_id)):
351354
if value is not None:
352355
clauses.append(f"{field}=?")
353356
params.append(value)

‎learning_engine.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,16 +150,18 @@ def artifact_context(self, run: dict[str, str]) -> tuple[str, list[dict[str, Any
150150
return "\n".join(lines), receipts
151151

152152
def complete_run(self, run: dict[str, str], *, result: str, receipts: list[dict[str, Any]],
153-
tools: list[dict[str, Any]], tokens: int, latency: float,
153+
tools: list[dict[str, Any]], tokens: int | None, latency: float | None,
154154
acceptance: list[dict[str, Any]] | None = None,
155155
provider_error: bool = False) -> None:
156156
errors = sum(1 for item in tools if not item.get("success"))
157157
contains_secret = bool(_SECRET_RE.search(json.dumps(
158158
{"task": run.get("task"), "result": result, "tools": tools}, ensure_ascii=False,
159159
)))
160160
payload = {**run, "result": str(result)[:4000], "receipts": receipts, "tools": tools[:50],
161-
"tool_calls": len(tools), "errors": errors, "tokens": max(0, int(tokens)),
162-
"latency": max(0.0, float(latency)), "provider_error": bool(provider_error),
161+
"tool_calls": len(tools), "errors": errors,
162+
"tokens": None if tokens is None else max(0, int(tokens)),
163+
"latency": None if latency is None else max(0.0, float(latency)),
164+
"provider_error": bool(provider_error),
163165
"acceptance_evidence": (acceptance or [])[:20],
164166
"contains_secret": contains_secret,
165167
"eligible": not provider_error and not contains_secret and len(tools) >= 2}
@@ -652,7 +654,7 @@ def pending_reviews(self, *, min_age_seconds: float = 30.0, limit: int = 1) -> l
652654
recurrence = sum(item["payload"].get("task_signature") == payload.get("task_signature")
653655
for item in completed)
654656
score = recurrence * 2 + int(payload["run_id"] in requested) * 5 + int(payload.get("errors", 0)) * 2
655-
score += min(5, int(payload.get("tokens", 0)) // 1000)
657+
score += min(5, int(payload.get("tokens") or 0) // 1000)
656658
candidates.append((score, event["created_at"], payload))
657659
candidates.sort(key=lambda item: (-item[0], item[1]))
658660
return [payload for _, _, payload in candidates[:limit]]
@@ -803,8 +805,8 @@ def metrics(self, profile: str | None = None) -> dict[str, Any]:
803805
"correction_rate": (sum(bool(item.get("correction")) for item in verified) / len(verified)) if verified else None,
804806
"repeated_error_rate": (sum(item.get("errors", 0) > 1 for item in completed) / len(completed)) if completed else None,
805807
"tool_calls": sum(int(item.get("tool_calls", 0)) for item in completed),
806-
"tokens": sum(int(item.get("tokens", 0)) for item in completed),
807-
"latency": sum(float(item.get("latency", 0.0)) for item in completed),
808+
"tokens": sum(int(item.get("tokens") or 0) for item in completed),
809+
"latency": sum(float(item.get("latency") or 0.0) for item in completed),
808810
"task_families": families,
809811
"context_chars": sum(sum(int(receipt.get("chars", 0)) for receipt in item.get("receipts", []))
810812
for item in outcomes),

‎main.py‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2348,7 +2348,7 @@ def _run_subagent_tool(profile: AgentProfile, action: str, args: Any, tools: set
23482348
}
23492349

23502350

2351-
def _run_subagent_llm(profile: AgentProfile, task: str, context: list[dict[str, Any]], tools: set[str]) -> dict[str, Any]:
2351+
def _run_subagent_llm_result(profile: AgentProfile, task: str, context: list[dict[str, Any]], tools: set[str]) -> dict[str, Any]:
23522352
memory_lines = "\n".join(
23532353
f"- kind={item.get('kind')} confidence={item.get('confidence', 0):.2f}: {str(item.get('content', ''))[:500]}"
23542354
for item in context
@@ -2421,7 +2421,66 @@ def _run_subagent_llm(profile: AgentProfile, task: str, context: list[dict[str,
24212421
return {"result": response, "tool_records": tool_records, "evidence": evidence}
24222422

24232423

2424-
subagent_manager = SubAgentManager(memory_bank, runner=_run_subagent_llm, learning_engine=learning_engine)
2424+
def _subagent_usage_metrics(run_id: str, started: float) -> dict[str, Any]:
2425+
"""Read one sub-agent's actual provider usage from the durable ledger."""
2426+
attempts = memory_bank.store.list_usage_attempts(
2427+
user_id=memory_bank.user_id, workspace_id=memory_bank.workspace_id, run_id=run_id,
2428+
)
2429+
totals = memory_bank.store.usage_totals(
2430+
user_id=memory_bank.user_id, workspace_id=memory_bank.workspace_id, run_id=run_id,
2431+
)
2432+
2433+
def known_total(field: str) -> int | None:
2434+
if not attempts or any(item.get(field) is None for item in attempts):
2435+
return None
2436+
return int(totals[field])
2437+
2438+
provider_models = sorted({f"{item['provider']}:{item['model']}" for item in attempts})
2439+
statuses = {str(item.get("usage_status") or "unknown") for item in attempts}
2440+
return {
2441+
"provider_model": provider_models[0] if len(provider_models) == 1 else (
2442+
"mixed" if provider_models else "unknown"
2443+
),
2444+
"provider_models": provider_models,
2445+
"attempts": int(totals["attempts"]),
2446+
"prompt_tokens": known_total("prompt_tokens"),
2447+
"completion_tokens": known_total("completion_tokens"),
2448+
"reasoning_tokens": known_total("reasoning_tokens"),
2449+
"tokens": (
2450+
int(totals["prompt_tokens"]) + int(totals["completion_tokens"])
2451+
if known_total("prompt_tokens") is not None and known_total("completion_tokens") is not None else None
2452+
),
2453+
"latency_ms": round((time.monotonic() - started) * 1000, 3),
2454+
"usage_status": (
2455+
next(iter(statuses)) if len(statuses) == 1 else "mixed" if statuses else "unknown"
2456+
),
2457+
}
2458+
2459+
2460+
def _run_subagent_llm(profile: AgentProfile, task: str, context: list[dict[str, Any]], tools: set[str], *,
2461+
run_id: str | None = None) -> dict[str, Any]:
2462+
started = time.monotonic()
2463+
token = _active_usage_run_id.set(run_id) if run_id else None
2464+
try:
2465+
result = _run_subagent_llm_result(profile, task, context, tools)
2466+
if run_id:
2467+
result["metrics"] = _subagent_usage_metrics(run_id, started)
2468+
return result
2469+
finally:
2470+
if token is not None:
2471+
_active_usage_run_id.reset(token)
2472+
2473+
2474+
def _subagent_provider_model() -> str:
2475+
if _provider_config and DEEPSEEK_MODEL:
2476+
return f"{_provider_config.provider}:{DEEPSEEK_MODEL}"
2477+
return "unknown"
2478+
2479+
2480+
subagent_manager = SubAgentManager(
2481+
memory_bank, runner=_run_subagent_llm, learning_engine=learning_engine,
2482+
provider_model=_subagent_provider_model,
2483+
)
24252484

24262485
# ---- Tool to let agent examine its own memory ----
24272486
def _check_stored_data(args: str) -> str:

‎subagents.py‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import re
6+
import time
67
import uuid
78
from dataclasses import dataclass, field
89
from typing import Any, Callable, TYPE_CHECKING
@@ -27,11 +28,13 @@ class AgentProfile:
2728

2829

2930
class SubAgentManager:
30-
def __init__(self, memory: MemoryBank, *, runner: Callable[[AgentProfile, str, list[dict[str, Any]], set[str]], str | dict[str, Any]] | None = None,
31-
learning_engine: "LearningEngine | None" = None):
31+
def __init__(self, memory: MemoryBank, *, runner: Callable[..., str | dict[str, Any]] | None = None,
32+
learning_engine: "LearningEngine | None" = None,
33+
provider_model: Callable[[], str] | None = None):
3234
self.memory = memory
3335
self.runner = runner
3436
self.learning_engine = learning_engine
37+
self.provider_model = provider_model
3538
self.profiles: dict[str, AgentProfile] = {}
3639
self.register(AgentProfile("researcher", "Research the task, cite evidence, and separate facts from assumptions.", "readonly"))
3740
self.register(AgentProfile("coder", "Inspect and modify code only when requested; verify every change.", "workspace"))
@@ -71,10 +74,11 @@ def run(self, profile_name: str, task: str, *, workspace_id: str | None = None)
7174
task, n_results=5, profile=profile.name,
7275
task_signature=self.learning_engine.task_signature(profile.name, task) if self.learning_engine else None,
7376
) if profile.memory_scope != "none" else [])
77+
expected_provider_model = self.provider_model() if self.provider_model else "unknown"
7478
learning_run = None
7579
receipts: list[dict[str, Any]] = []
7680
if self.learning_engine and profile.evolution_enabled and profile.name in {"coder", "researcher"}:
77-
learning_run = self.learning_engine.begin_run(profile.name, task)
81+
learning_run = self.learning_engine.begin_run(profile.name, task, provider_model=expected_provider_model)
7882
guidance, receipts = self.learning_engine.artifact_context(learning_run)
7983
if guidance:
8084
context.append({"kind": "learned_guidance", "confidence": 1.0, "content": guidance})
@@ -83,21 +87,51 @@ def run(self, profile_name: str, task: str, *, workspace_id: str | None = None)
8387
user_id=self.memory.user_id, workspace_id=workspace_id, session_id=run_id)
8488
tool_records: list[dict[str, Any]] = []
8589
evidence: list[dict[str, Any]] = []
90+
metrics: dict[str, Any] = {
91+
"provider_model": expected_provider_model or "unknown", "provider_models": [], "attempts": None,
92+
"prompt_tokens": None, "completion_tokens": None, "reasoning_tokens": None, "tokens": None,
93+
"latency_ms": None, "usage_status": "unknown",
94+
}
8695
provider_error = False
96+
started = time.monotonic()
8797
if self.runner is None:
8898
result = "Sub-agent runner is not configured."
8999
else:
90100
try:
91-
raw_result = self.runner(profile, task, context, tools)
101+
raw_result = self.runner(profile, task, context, tools, run_id=run_id)
92102
if isinstance(raw_result, dict):
93103
result = str(raw_result.get("result", ""))
94104
tool_records = [item for item in raw_result.get("tool_records", []) if isinstance(item, dict)]
95105
evidence = [item for item in raw_result.get("evidence", []) if isinstance(item, dict)]
106+
if isinstance(raw_result.get("metrics"), dict):
107+
metrics.update({key: raw_result["metrics"][key] for key in metrics
108+
if key in raw_result["metrics"]})
96109
else:
97110
result = str(raw_result)
98111
except Exception as exc:
99112
provider_error = True
100113
result = f"Sub-agent failed: {type(exc).__name__}: {exc}"
114+
if not isinstance(metrics.get("provider_model"), str) or not metrics["provider_model"].strip():
115+
metrics["provider_model"] = expected_provider_model or "unknown"
116+
metrics["provider_model"] = str(metrics["provider_model"]).strip()[:192]
117+
metrics["provider_models"] = [str(item)[:192] for item in metrics["provider_models"]] if isinstance(
118+
metrics["provider_models"], (list, tuple, set)
119+
) else []
120+
if metrics["usage_status"] not in {"authoritative", "estimated", "mixed", "unknown"}:
121+
metrics["usage_status"] = "unknown"
122+
for field in ("attempts", "prompt_tokens", "completion_tokens", "reasoning_tokens", "tokens"):
123+
try:
124+
metrics[field] = None if metrics[field] is None else max(0, int(metrics[field]))
125+
except (TypeError, ValueError):
126+
metrics[field] = None
127+
try:
128+
metrics["latency_ms"] = None if metrics["latency_ms"] is None else max(0.0, float(metrics["latency_ms"]))
129+
except (TypeError, ValueError):
130+
metrics["latency_ms"] = None
131+
if metrics.get("latency_ms") is None:
132+
metrics["latency_ms"] = (time.monotonic() - started) * 1000
133+
if learning_run:
134+
learning_run["provider_model"] = metrics["provider_model"]
101135

102136
for record in tool_records:
103137
payload = {"run_id": run_id, **record}
@@ -119,13 +153,16 @@ def run(self, profile_name: str, task: str, *, workspace_id: str | None = None)
119153
"result": str(result)[:4000],
120154
"tool_receipts": tool_records[:50],
121155
"acceptance_evidence": evidence[:20],
156+
"metrics": metrics,
122157
"provider_error": provider_error},
123158
user_id=self.memory.user_id, workspace_id=workspace_id, session_id=run_id)
124159
scoped_memory.add_log(str(result), kind="episodic", status="active",
125160
metadata={"subagent_run_id": run_id, "profile": profile.name})
126161
if learning_run:
127162
self.learning_engine.complete_run(learning_run, result=str(result), receipts=receipts,
128-
tools=tool_records, tokens=0, latency=0.0,
163+
tools=tool_records, tokens=metrics.get("tokens"),
164+
latency=(float(metrics["latency_ms"]) / 1000
165+
if metrics.get("latency_ms") is not None else None),
129166
acceptance=evidence, provider_error=provider_error)
130167
if evidence:
131168
self.learning_engine.record_outcome(
@@ -135,4 +172,4 @@ def run(self, profile_name: str, task: str, *, workspace_id: str | None = None)
135172
)
136173
return {"run_id": run_id, "profile": profile.name, "result": str(result),
137174
"tools": sorted(tools), "memory_scope": profile.memory_scope,
138-
"tool_receipts": tool_records, "evidence": evidence}
175+
"tool_receipts": tool_records, "evidence": evidence, "metrics": metrics}

‎tests/test_server.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ def test_subagent_api_executes_allowed_file_action_and_rejects_disallowed_action
5555
self.assertFalse((root / "denied.txt").exists())
5656
self.assertFalse(denied.json()["tool_receipts"][0]["success"])
5757
self.assertFalse(denied.json()["tool_receipts"][0]["authorized"])
58+
completed = next(event for event in manager.memory.store.list_events(
59+
"subagent.completed", workspace_id=server._agent.memory_bank.workspace_id,
60+
session_id=allowed.json()["run_id"], limit=10,
61+
))
62+
self.assertEqual(completed["payload"]["metrics"], allowed.json()["metrics"])
5863
events = manager.memory.store.list_events(
5964
workspace_id=server._agent.memory_bank.workspace_id,
6065
session_id=denied.json()["run_id"], limit=20,

0 commit comments

Comments
 (0)