@miller-tech/uap 1.129.2 → 1.129.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/model.d.ts.map +1 -1
- package/dist/cli/model.js +24 -8
- package/dist/cli/model.js.map +1 -1
- package/dist/cli/systemd-services.d.ts +6 -0
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +16 -0
- package/dist/cli/systemd-services.js.map +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +47 -9
- package/tools/agents/scripts/project_telemetry.py +121 -4
- package/tools/agents/tests/test_project_telemetry_events.py +95 -0
- package/tools/agents/tests/test_stream_telemetry.py +42 -7
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""record_from_request must feed the dashboard live panels, not just analytics.
|
|
2
|
+
|
|
3
|
+
Live Events / Performance read <project>/agents/data/memory/telemetry.db,
|
|
4
|
+
which only the TS producers wrote — plain claude+proxy sessions left those
|
|
5
|
+
panels empty forever. The proxy now mirrors each completed turn there.
|
|
6
|
+
"""
|
|
7
|
+
import importlib.util
|
|
8
|
+
import json
|
|
9
|
+
import sqlite3
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
pt_path = Path(__file__).resolve().parents[1] / "scripts" / "project_telemetry.py"
|
|
15
|
+
spec = importlib.util.spec_from_file_location("project_telemetry", pt_path)
|
|
16
|
+
pt = importlib.util.module_from_spec(spec)
|
|
17
|
+
spec.loader.exec_module(pt)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProjectTelemetryEventsTest(unittest.TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="pt-events-")
|
|
23
|
+
self.root = Path(self._tmp.name)
|
|
24
|
+
# A recordable UAP project: marker + existing memory dir (the writer
|
|
25
|
+
# must never scaffold into arbitrary derived directories).
|
|
26
|
+
(self.root / ".git").mkdir()
|
|
27
|
+
(self.root / "agents" / "data" / "memory").mkdir(parents=True)
|
|
28
|
+
|
|
29
|
+
def tearDown(self):
|
|
30
|
+
self._tmp.cleanup()
|
|
31
|
+
|
|
32
|
+
def _body(self):
|
|
33
|
+
# derive_project_dir walks absolute paths echoed in the request.
|
|
34
|
+
return {"messages": [{"role": "user", "content": f"edit {self.root}/src/main.rs"}]}
|
|
35
|
+
|
|
36
|
+
def test_turn_writes_analytics_event_and_perf_sample(self):
|
|
37
|
+
ok = pt.record_from_request(
|
|
38
|
+
self._body(),
|
|
39
|
+
"qwen-test",
|
|
40
|
+
{"input_tokens": 1000, "output_tokens": 50},
|
|
41
|
+
duration_ms=1234.5,
|
|
42
|
+
)
|
|
43
|
+
self.assertTrue(ok)
|
|
44
|
+
mem = self.root / "agents" / "data" / "memory"
|
|
45
|
+
|
|
46
|
+
con = sqlite3.connect(mem / "model_analytics.db")
|
|
47
|
+
row = con.execute(
|
|
48
|
+
"SELECT modelId, tokensIn, tokensOut, durationMs FROM task_outcomes"
|
|
49
|
+
).fetchone()
|
|
50
|
+
con.close()
|
|
51
|
+
self.assertEqual(row, ("qwen-test", 1000, 50, 1234))
|
|
52
|
+
|
|
53
|
+
con = sqlite3.connect(mem / "telemetry.db")
|
|
54
|
+
ev = con.execute(
|
|
55
|
+
"SELECT category, type, severity, title, metadata FROM dashboard_events"
|
|
56
|
+
).fetchone()
|
|
57
|
+
perf = con.execute("SELECT metric, duration_ms FROM perf_samples").fetchone()
|
|
58
|
+
con.close()
|
|
59
|
+
self.assertEqual(ev[0:3], ("model", "turn", "info"))
|
|
60
|
+
self.assertIn("qwen-test", ev[3])
|
|
61
|
+
meta = json.loads(ev[4])
|
|
62
|
+
self.assertEqual(meta["tokensIn"], 1000)
|
|
63
|
+
self.assertEqual(meta["tokensOut"], 50)
|
|
64
|
+
self.assertEqual(perf, ("proxy_turn_ms", 1234.5))
|
|
65
|
+
|
|
66
|
+
def test_schema_matches_ts_producer_shape(self):
|
|
67
|
+
# The TS side (telemetry-store.ts) CREATE IF NOT EXISTS must no-op on a
|
|
68
|
+
# DB the proxy created first — the column shape has to match exactly.
|
|
69
|
+
pt.record_from_request(self._body(), "m", {"input_tokens": 1, "output_tokens": 1})
|
|
70
|
+
con = sqlite3.connect(self.root / "agents" / "data" / "memory" / "telemetry.db")
|
|
71
|
+
cols = [r[1] for r in con.execute("PRAGMA table_info(dashboard_events)")]
|
|
72
|
+
perf_cols = [r[1] for r in con.execute("PRAGMA table_info(perf_samples)")]
|
|
73
|
+
con.close()
|
|
74
|
+
self.assertEqual(cols, ["id", "ts", "category", "type", "severity", "title", "detail", "metadata"])
|
|
75
|
+
self.assertEqual(perf_cols, ["id", "metric", "duration_ms", "ts"])
|
|
76
|
+
|
|
77
|
+
def test_never_scaffolds_memory_dir(self):
|
|
78
|
+
bare = Path(self._tmp.name) / "bare"
|
|
79
|
+
(bare / ".git").mkdir(parents=True)
|
|
80
|
+
body = {"messages": [{"role": "user", "content": f"see {bare}/x.txt"}]}
|
|
81
|
+
self.assertFalse(pt.record_from_request(body, "m", {"input_tokens": 1, "output_tokens": 1}))
|
|
82
|
+
self.assertFalse((bare / "agents").exists())
|
|
83
|
+
|
|
84
|
+
def test_zero_duration_skips_perf_sample_but_keeps_event(self):
|
|
85
|
+
pt.record_from_request(self._body(), "m", {"input_tokens": 5, "output_tokens": 5})
|
|
86
|
+
con = sqlite3.connect(self.root / "agents" / "data" / "memory" / "telemetry.db")
|
|
87
|
+
events = con.execute("SELECT count(*) FROM dashboard_events").fetchone()[0]
|
|
88
|
+
perf = con.execute("SELECT count(*) FROM perf_samples").fetchone()[0]
|
|
89
|
+
con.close()
|
|
90
|
+
self.assertEqual(events, 1)
|
|
91
|
+
self.assertEqual(perf, 0)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
unittest.main()
|
|
@@ -4,8 +4,11 @@ Regression for the frozen-dashboard incident (2026-07-08): the only
|
|
|
4
4
|
_record_project_telemetry call sites were on the non-stream / guarded
|
|
5
5
|
non-stream paths, so streaming sessions (the client default) wrote no
|
|
6
6
|
task_outcomes rows and per-project dashboards showed static data forever.
|
|
7
|
+
Follow-up coverage: real output tokens on tool-use turns (upstream usage via
|
|
8
|
+
stream_options.include_usage, char-estimate fallback) and turn duration.
|
|
7
9
|
"""
|
|
8
10
|
import asyncio
|
|
11
|
+
import json
|
|
9
12
|
import importlib.util
|
|
10
13
|
import unittest
|
|
11
14
|
from pathlib import Path
|
|
@@ -30,24 +33,32 @@ class FakeOpenAIStream:
|
|
|
30
33
|
pass
|
|
31
34
|
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
TEXT_TURN_LINES = [
|
|
34
37
|
'data: {"choices": [{"delta": {"content": "hello"}, "finish_reason": null}]}',
|
|
35
38
|
'data: {"choices": [{"delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 456, "completion_tokens": 5}}',
|
|
36
39
|
"data: [DONE]",
|
|
37
40
|
]
|
|
38
41
|
|
|
42
|
+
# A pure tool-call turn whose upstream sends NO usage (older servers ignore
|
|
43
|
+
# stream_options.include_usage): output must be estimated from the generated
|
|
44
|
+
# tool-call arguments, not recorded as 0.
|
|
45
|
+
TOOL_TURN_NO_USAGE_LINES = [
|
|
46
|
+
'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": {"name": "Bash", "arguments": ""}}]}, "finish_reason": null}]}',
|
|
47
|
+
'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\\"command\\": \\"echo hello world this is a long command string\\"}"}}]}, "finish_reason": null}]}',
|
|
48
|
+
'data: {"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}',
|
|
49
|
+
"data: [DONE]",
|
|
50
|
+
]
|
|
51
|
+
|
|
39
52
|
|
|
40
53
|
class StreamTelemetryTest(unittest.TestCase):
|
|
41
|
-
def _drive(self, body):
|
|
54
|
+
def _drive(self, body, lines=TEXT_TURN_LINES):
|
|
42
55
|
monitor = ap.SessionMonitor()
|
|
43
56
|
monitor.last_input_tokens = 123
|
|
44
57
|
recorded = []
|
|
45
58
|
saved = ap._record_project_telemetry
|
|
46
|
-
ap._record_project_telemetry = lambda b, m, u: recorded.append((b, m, u))
|
|
59
|
+
ap._record_project_telemetry = lambda b, m, u, d=0.0: recorded.append((b, m, u, d))
|
|
47
60
|
try:
|
|
48
|
-
gen = ap.stream_anthropic_response(
|
|
49
|
-
FakeOpenAIStream(OPENAI_LINES), "test-model", monitor, body
|
|
50
|
-
)
|
|
61
|
+
gen = ap.stream_anthropic_response(FakeOpenAIStream(lines), "test-model", monitor, body)
|
|
51
62
|
|
|
52
63
|
async def consume():
|
|
53
64
|
return [frame async for frame in gen]
|
|
@@ -62,13 +73,37 @@ class StreamTelemetryTest(unittest.TestCase):
|
|
|
62
73
|
frames, recorded = self._drive(body)
|
|
63
74
|
self.assertTrue(any("message_stop" in f for f in frames))
|
|
64
75
|
self.assertEqual(len(recorded), 1)
|
|
65
|
-
recorded_body, model, usage = recorded[0]
|
|
76
|
+
recorded_body, model, usage, duration_ms = recorded[0]
|
|
66
77
|
self.assertIs(recorded_body, body)
|
|
67
78
|
self.assertEqual(model, "test-model")
|
|
68
79
|
# Upstream's final usage chunk wins over the request-side estimate and
|
|
69
80
|
# the per-delta chunk counter (which misses tool-call turns).
|
|
70
81
|
self.assertEqual(usage.get("input_tokens"), 456)
|
|
71
82
|
self.assertEqual(usage.get("output_tokens"), 5)
|
|
83
|
+
self.assertGreaterEqual(duration_ms, 0)
|
|
84
|
+
|
|
85
|
+
def test_tool_turn_without_upstream_usage_estimates_output_tokens(self):
|
|
86
|
+
frames, recorded = self._drive({"messages": []}, TOOL_TURN_NO_USAGE_LINES)
|
|
87
|
+
self.assertEqual(len(recorded), 1)
|
|
88
|
+
_, _, usage, _ = recorded[0]
|
|
89
|
+
# ~60 chars of tool arguments -> well above zero at ~4 chars/token.
|
|
90
|
+
self.assertGreater(usage.get("output_tokens"), 5)
|
|
91
|
+
|
|
92
|
+
def test_xml_recovered_tool_turn_without_usage_still_records(self):
|
|
93
|
+
# The XML-recovery path plants a bare sentinel in tool_calls_by_index
|
|
94
|
+
# (_xml_recovered = True); the char-estimate fallback must not choke on
|
|
95
|
+
# it (regression: AttributeError after message_stop, telemetry lost).
|
|
96
|
+
xml = '<tool_call>{"name": "Bash", "arguments": {"command": "ls -la"}}</tool_call>'
|
|
97
|
+
lines = [
|
|
98
|
+
f'data: {json.dumps({"choices": [{"delta": {"content": xml}, "finish_reason": None}]})}',
|
|
99
|
+
'data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}',
|
|
100
|
+
"data: [DONE]",
|
|
101
|
+
]
|
|
102
|
+
frames, recorded = self._drive({"messages": []}, lines)
|
|
103
|
+
self.assertTrue(any("tool_use" in f for f in frames)) # recovery actually fired
|
|
104
|
+
self.assertEqual(len(recorded), 1)
|
|
105
|
+
_, _, usage, _ = recorded[0]
|
|
106
|
+
self.assertGreater(usage.get("output_tokens"), 0)
|
|
72
107
|
|
|
73
108
|
def test_telemetry_recorded_after_client_visible_stream_end(self):
|
|
74
109
|
# The recorder must not delay or reorder client frames: message_stop is
|