agentglow 0.1.0__py3-none-any.whl
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.
- agentglow/__init__.py +7 -0
- agentglow/claude_code.py +367 -0
- agentglow/cli.py +33 -0
- agentglow/mapper.py +645 -0
- agentglow/otel.py +131 -0
- agentglow/server.py +263 -0
- agentglow/state.py +72 -0
- agentglow/static/assets/ClusterBall-DyuCTDj5.css +1 -0
- agentglow/static/assets/ClusterBall-cE4B1CvW.js +4786 -0
- agentglow/static/assets/Gallery-CQEYu2WV.css +1 -0
- agentglow/static/assets/Gallery-D8dIEaZ7.js +1 -0
- agentglow/static/assets/Grid-NYiWgi-G.js +61 -0
- agentglow/static/assets/OrbitControls-DYjT8CYE.js +1 -0
- agentglow/static/assets/Sparkles-kPa0rwA-.js +35 -0
- agentglow/static/assets/Stars-TFYkdjpR.js +24 -0
- agentglow/static/assets/Trail-Bo6rV9pc.js +114 -0
- agentglow/static/assets/index-4nOSPMUp.js +21 -0
- agentglow/static/assets/index-B_Ox3Guu.css +1 -0
- agentglow/static/assets/index-BcWPDQVh.js +84 -0
- agentglow/static/assets/index-BwnYLMbM.js +1 -0
- agentglow/static/assets/index-Bz62ycay.js +97 -0
- agentglow/static/assets/index-CIbdJsCm.css +1 -0
- agentglow/static/assets/index-CR_gh-V0.js +1 -0
- agentglow/static/assets/index-CUzplisx.js +118 -0
- agentglow/static/assets/index-CZfkKsK0.js +159 -0
- agentglow/static/assets/index-CaPh9M_r.js +68 -0
- agentglow/static/assets/index-CjOTUtcY.js +100 -0
- agentglow/static/assets/index-CmsO_P9t.js +50 -0
- agentglow/static/assets/index-Cv8on1m7.js +226 -0
- agentglow/static/assets/index-D59RSjRv.js +85 -0
- agentglow/static/assets/index-D9wBYbu3.css +1 -0
- agentglow/static/assets/index-DfT3puT3.js +47 -0
- agentglow/static/assets/index-Dux7S8bO.js +1 -0
- agentglow/static/assets/index-HnuICwcD.css +1 -0
- agentglow/static/assets/index-QV2PECrF.js +140 -0
- agentglow/static/assets/index-Xr265JCV.js +27 -0
- agentglow/static/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- agentglow/static/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- agentglow/static/assets/spread-DjjdTIk9.js +1 -0
- agentglow/static/index.html +14 -0
- agentglow/watch.py +91 -0
- agentglow-0.1.0.dist-info/METADATA +110 -0
- agentglow-0.1.0.dist-info/RECORD +45 -0
- agentglow-0.1.0.dist-info/WHEEL +4 -0
- agentglow-0.1.0.dist-info/entry_points.txt +2 -0
agentglow/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""AgentGlow: live 3D views of agent systems, driven only by OpenTelemetry spans."""
|
|
2
|
+
__version__ = "0.1.0"
|
|
3
|
+
|
|
4
|
+
from .otel import LiveSpanProcessor # noqa: E402
|
|
5
|
+
from .watch import register_mcp, watch # noqa: E402
|
|
6
|
+
|
|
7
|
+
__all__ = ["watch", "register_mcp", "LiveSpanProcessor", "__version__"]
|
agentglow/claude_code.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Claude Code hooks → synthetic live spans, so the regular span mapper renders Claude Code's own activity.
|
|
2
|
+
|
|
3
|
+
Point Claude Code's `"type": "http"` hooks at `POST /v1/claude-code` (see examples/claude-code/). Each hook payload
|
|
4
|
+
becomes `{"kind": "start"|"end", "span": {...}}` items (the `/v1/live` shape), shaped to hit the mapper's rules:
|
|
5
|
+
|
|
6
|
+
- One user prompt = one run (`agentglow.run.id` = `<session>:<n>`, topic = the prompt, workflow `claude-code`).
|
|
7
|
+
- Main agent = agent span `claude` (`agentglow.agent`), opened on UserPromptSubmit, closed on Stop
|
|
8
|
+
(`last_assistant_message` → `agentglow.final`).
|
|
9
|
+
- Agent/Task tool call = TOOL span named `task` under the calling agent; SubagentStart opens an agent span named
|
|
10
|
+
after `agent_type` under that tool span (→ `spawn` with `subagent: true`); SubagentStop closes it (its
|
|
11
|
+
`last_assistant_message` is the result message back to the parent).
|
|
12
|
+
- Any other tool = TOOL span under the agent that called it (`agent_id` present → that subagent, else main).
|
|
13
|
+
`mcp__<server>__<tool>` also sets `agentglow.mcp.server`/`agentglow.mcp.tool` (→ `mcp` call/result).
|
|
14
|
+
- Hooks carry no token counts, so the time between tool calls is an LLM span with no usage attributes: the mapper
|
|
15
|
+
shows the agent thinking and emits an `llm` pulse with 0 tokens (no invented numbers).
|
|
16
|
+
|
|
17
|
+
Hooks may be async (delivered out of order): a PostToolUse seen before its PreToolUse is remembered and the late
|
|
18
|
+
start is emitted already ended; events for an unknown/closed turn are dropped. State is per session, bounded, and
|
|
19
|
+
dangling spans are ended on SessionEnd or after `idle_ms` without events.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from collections import OrderedDict
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
AGENT_TOOLS = {"Agent", "Task"}
|
|
31
|
+
MAX_SESSIONS = 64
|
|
32
|
+
MAX_DONE_IDS = 512
|
|
33
|
+
PREVIEW = 600
|
|
34
|
+
HOLD_MS = 1000 # a SubagentStart that beat its Agent PreToolUse (async hooks) waits this long for it
|
|
35
|
+
GRACE_MS = 8000 # main agent outlives its Stop while background subagents run, then this long for their wrap-up turn
|
|
36
|
+
NOTIFY_RE = re.compile(r"^\s*<task-notification>", re.I)
|
|
37
|
+
SUMMARY_RE = re.compile(r"<summary>(.*?)</summary>", re.S)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hex(n: int) -> str:
|
|
41
|
+
return os.urandom(n).hex()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _meta(path: Any) -> dict:
|
|
45
|
+
"""Claude Code writes `<agent transcript>.meta.json` next to a subagent's transcript: {agentType, description,
|
|
46
|
+
toolUseId, ...}. toolUseId pairs the subagent with its exact Agent tool call (parallel same-type subagents)."""
|
|
47
|
+
if not isinstance(path, str) or not path.endswith(".jsonl"):
|
|
48
|
+
return {}
|
|
49
|
+
try:
|
|
50
|
+
with open(path[:-6] + ".meta.json", "rb") as f:
|
|
51
|
+
d = json.loads(f.read(65536))
|
|
52
|
+
return d if isinstance(d, dict) else {}
|
|
53
|
+
except Exception:
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _preview(v: Any) -> str:
|
|
58
|
+
s = v if isinstance(v, str) else json.dumps(v, default=str)
|
|
59
|
+
return s if len(s) <= PREVIEW else s[:PREVIEW] + " …"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class _Agent:
|
|
64
|
+
name: str
|
|
65
|
+
span: dict
|
|
66
|
+
turn: "_Turn"
|
|
67
|
+
llm: dict | None = None
|
|
68
|
+
tools: dict = field(default_factory=dict) # tool_use_id -> span
|
|
69
|
+
closed: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class _Turn:
|
|
74
|
+
run_id: str
|
|
75
|
+
trace_id: str
|
|
76
|
+
main: _Agent | None = None
|
|
77
|
+
subs: dict = field(default_factory=dict) # agent_id -> _Agent
|
|
78
|
+
tasks: list = field(default_factory=list) # [tool_use_id, subagent_type, task span, claimed]
|
|
79
|
+
stopped: bool = False
|
|
80
|
+
deferred: bool = False # Stop seen but background subagents still run: main stays on screen, waiting
|
|
81
|
+
final: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class _Session:
|
|
86
|
+
id: str
|
|
87
|
+
last: int
|
|
88
|
+
n: int = 0
|
|
89
|
+
turn: _Turn | None = None # current main-thread turn
|
|
90
|
+
agents: dict = field(default_factory=dict) # agent_id -> _Agent (outlives its turn's Stop: background agents)
|
|
91
|
+
done_ids: OrderedDict = field(default_factory=OrderedDict) # tool_use_ids whose PostToolUse came first
|
|
92
|
+
held: dict = field(default_factory=dict) # agent_id -> (SubagentStart payload, meta, ts) awaiting its Agent call
|
|
93
|
+
model: str = ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ClaudeCodeAdapter:
|
|
97
|
+
def __init__(self, idle_ms: int = 30 * 60_000, max_sessions: int = MAX_SESSIONS) -> None:
|
|
98
|
+
self.sessions: OrderedDict[str, _Session] = OrderedDict()
|
|
99
|
+
self.idle_ms = idle_ms
|
|
100
|
+
self.max_sessions = max_sessions
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ public
|
|
103
|
+
def handle(self, p: dict, now: int) -> list[dict]:
|
|
104
|
+
"""One hook payload → live items for Hub.ingest_live. Never raises on odd input."""
|
|
105
|
+
if not isinstance(p, dict):
|
|
106
|
+
return []
|
|
107
|
+
sid = str(p.get("session_id") or "default")
|
|
108
|
+
ev = str(p.get("hook_event_name") or "")
|
|
109
|
+
out: list[dict] = []
|
|
110
|
+
s = self.sessions.get(sid)
|
|
111
|
+
if s is None:
|
|
112
|
+
if ev == "SessionEnd":
|
|
113
|
+
return out
|
|
114
|
+
s = self.sessions[sid] = _Session(sid, now)
|
|
115
|
+
while len(self.sessions) > self.max_sessions:
|
|
116
|
+
_, old = self.sessions.popitem(last=False)
|
|
117
|
+
self._close_session(old, now, out)
|
|
118
|
+
self.sessions.move_to_end(sid)
|
|
119
|
+
s.last = now
|
|
120
|
+
aid = p.get("agent_id")
|
|
121
|
+
if aid and str(aid) in s.held and ev != "SubagentStart": # the subagent is active: show it now
|
|
122
|
+
self._spawn_sub(s, *s.held.pop(str(aid)), now, out)
|
|
123
|
+
fn = getattr(self, f"_on_{ev}", None)
|
|
124
|
+
if fn:
|
|
125
|
+
fn(s, p, now, out)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
def tick(self, now: int) -> list[dict]:
|
|
129
|
+
out: list[dict] = []
|
|
130
|
+
for sid, s in list(self.sessions.items()):
|
|
131
|
+
for aid, (hp, meta, ts) in list(s.held.items()):
|
|
132
|
+
if now - ts >= HOLD_MS:
|
|
133
|
+
del s.held[aid]
|
|
134
|
+
self._spawn_sub(s, hp, meta, ts, now, out)
|
|
135
|
+
t = s.turn
|
|
136
|
+
if t and t.deferred and not t.subs and now - s.last >= GRACE_MS:
|
|
137
|
+
self._stop_turn(s, t, t.final, now, out, force=True)
|
|
138
|
+
if now - s.last >= self.idle_ms:
|
|
139
|
+
self._close_session(s, now, out)
|
|
140
|
+
self.sessions.pop(sid, None)
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------ events
|
|
144
|
+
def _on_SessionStart(self, s: _Session, p: dict, now: int, out: list) -> None:
|
|
145
|
+
s.model = str(p.get("model") or s.model)
|
|
146
|
+
|
|
147
|
+
def _on_UserPromptSubmit(self, s: _Session, p: dict, now: int, out: list) -> None:
|
|
148
|
+
prompt = str(p.get("user_message") or p.get("prompt") or "").strip()
|
|
149
|
+
t = s.turn
|
|
150
|
+
if t and t.deferred and NOTIFY_RE.match(prompt): # a background subagent reported back: same run continues
|
|
151
|
+
t.stopped = t.deferred = False
|
|
152
|
+
assert t.main is not None
|
|
153
|
+
self._start_llm(t.main, now, out)
|
|
154
|
+
return
|
|
155
|
+
if t and t.deferred:
|
|
156
|
+
self._stop_turn(s, t, t.final, now, out, force=True)
|
|
157
|
+
elif t and not t.stopped: # previous turn never got its Stop (interrupted)
|
|
158
|
+
self._stop_turn(s, t, "", now, out, status="error", force=True)
|
|
159
|
+
if NOTIFY_RE.match(prompt):
|
|
160
|
+
m = SUMMARY_RE.search(prompt)
|
|
161
|
+
prompt = f"background task done: {m[1].strip()}" if m else "background task done"
|
|
162
|
+
self._open_turn(s, prompt, now, out)
|
|
163
|
+
|
|
164
|
+
def _on_PreToolUse(self, s: _Session, p: dict, now: int, out: list) -> None:
|
|
165
|
+
ag = self._agent_for(s, p, now, out, open_turn=True)
|
|
166
|
+
if ag is None:
|
|
167
|
+
return
|
|
168
|
+
tid = str(p.get("tool_use_id") or _hex(8))
|
|
169
|
+
if tid in ag.tools:
|
|
170
|
+
return
|
|
171
|
+
name = str(p.get("tool_name") or "tool")
|
|
172
|
+
tin = p.get("tool_input") if isinstance(p.get("tool_input"), dict) else {}
|
|
173
|
+
self._end_llm(ag, now, out)
|
|
174
|
+
if name in AGENT_TOOLS:
|
|
175
|
+
sub = str(tin.get("subagent_type") or "general-purpose")
|
|
176
|
+
desc = str(tin.get("description") or tin.get("prompt") or "")
|
|
177
|
+
span = self._span(ag.turn, ag.span, "task", now, {
|
|
178
|
+
"openinference.span.kind": "TOOL",
|
|
179
|
+
"input.value": json.dumps({"subagent_type": sub, "description": desc[:300]}),
|
|
180
|
+
"agentglow.claude_code.prompt": _preview(tin.get("prompt") or desc),
|
|
181
|
+
})
|
|
182
|
+
# a SubagentStart that arrived first (async hooks) created a stand-in task span: adopt it instead
|
|
183
|
+
for t in ag.turn.tasks:
|
|
184
|
+
if t[0] is None and t[1] == sub:
|
|
185
|
+
t[0] = tid
|
|
186
|
+
ag.tools[tid] = t[2]
|
|
187
|
+
return
|
|
188
|
+
ag.turn.tasks.append([tid, sub, span, False])
|
|
189
|
+
out.append({"kind": "start", "span": span})
|
|
190
|
+
ag.tools[tid] = span
|
|
191
|
+
for aid, (hp, meta, ts) in list(s.held.items()): # its SubagentStart came first: spawn it now
|
|
192
|
+
if meta.get("toolUseId") == tid or (not meta.get("toolUseId") and str(hp.get("agent_type")) == sub):
|
|
193
|
+
del s.held[aid]
|
|
194
|
+
self._spawn_sub(s, hp, meta, ts, now, out)
|
|
195
|
+
break
|
|
196
|
+
if s.done_ids.pop(tid, None) is not None:
|
|
197
|
+
self._end_tool(ag, tid, now, out)
|
|
198
|
+
return
|
|
199
|
+
else:
|
|
200
|
+
attrs = {"openinference.span.kind": "TOOL", "tool.name": name, "input.value": _preview(tin)}
|
|
201
|
+
if name.startswith("mcp__"):
|
|
202
|
+
parts = name.split("__", 2)
|
|
203
|
+
if len(parts) == 3 and parts[1] and parts[2]:
|
|
204
|
+
attrs.update({"agentglow.mcp.server": parts[1], "agentglow.mcp.tool": parts[2], "tool.name": parts[2]})
|
|
205
|
+
name = parts[2]
|
|
206
|
+
span = self._span(ag.turn, ag.span, name, now, attrs)
|
|
207
|
+
out.append({"kind": "start", "span": span})
|
|
208
|
+
ag.tools[tid] = span
|
|
209
|
+
if s.done_ids.pop(tid, None) is not None: # its PostToolUse already came (async reorder)
|
|
210
|
+
self._end_tool(ag, tid, now, out)
|
|
211
|
+
|
|
212
|
+
def _on_PostToolUse(self, s: _Session, p: dict, now: int, out: list, status: str = "ok") -> None:
|
|
213
|
+
tid = str(p.get("tool_use_id") or "")
|
|
214
|
+
ag = self._agent_for(s, p, now, out, open_turn=False)
|
|
215
|
+
if ag is None or tid not in ag.tools:
|
|
216
|
+
if tid:
|
|
217
|
+
s.done_ids[tid] = None
|
|
218
|
+
while len(s.done_ids) > MAX_DONE_IDS:
|
|
219
|
+
s.done_ids.popitem(last=False)
|
|
220
|
+
return
|
|
221
|
+
extra = {}
|
|
222
|
+
if status == "error" and p.get("error"):
|
|
223
|
+
extra["exception.message"] = _preview(str(p["error"]))
|
|
224
|
+
self._end_tool(ag, tid, now, out, status, extra)
|
|
225
|
+
if not ag.tools and not ag.closed and not (ag is ag.turn.main and ag.turn.stopped):
|
|
226
|
+
self._start_llm(ag, now, out)
|
|
227
|
+
|
|
228
|
+
def _on_PostToolUseFailure(self, s: _Session, p: dict, now: int, out: list) -> None:
|
|
229
|
+
self._on_PostToolUse(s, p, now, out, status="error")
|
|
230
|
+
|
|
231
|
+
def _on_SubagentStart(self, s: _Session, p: dict, now: int, out: list) -> None:
|
|
232
|
+
aid = str(p.get("agent_id") or "")
|
|
233
|
+
if not aid or aid in s.agents or aid in s.held:
|
|
234
|
+
return
|
|
235
|
+
meta = _meta(p.get("agent_transcript_path"))
|
|
236
|
+
turn = s.turn
|
|
237
|
+
tid = meta.get("toolUseId")
|
|
238
|
+
has_task = turn is not None and any(not t[3] and (t[0] == tid if tid else True) for t in turn.tasks)
|
|
239
|
+
if not has_task: # async hooks: its Agent PreToolUse may still be in flight
|
|
240
|
+
s.held[aid] = (p, meta, now)
|
|
241
|
+
return
|
|
242
|
+
self._spawn_sub(s, p, meta, now, now, out)
|
|
243
|
+
|
|
244
|
+
def _spawn_sub(self, s: _Session, p: dict, meta: dict, ts: int, now: int, out: list) -> None:
|
|
245
|
+
aid = str(p.get("agent_id") or "")
|
|
246
|
+
typ = str(p.get("agent_type") or meta.get("agentType") or "subagent")
|
|
247
|
+
turn = s.turn if s.turn and not s.turn.stopped else None
|
|
248
|
+
turn = turn or s.turn or self._open_turn(s, "", now, out)
|
|
249
|
+
tid = meta.get("toolUseId")
|
|
250
|
+
free = [t for t in turn.tasks if not t[3]]
|
|
251
|
+
task = (next((t for t in free if tid and t[0] == tid), None) or next((t for t in free if t[1] == typ), None)
|
|
252
|
+
or (free[0] if free else None))
|
|
253
|
+
if task is None: # no Agent call seen for it: stand-in task span under main
|
|
254
|
+
assert turn.main is not None
|
|
255
|
+
span = self._span(turn, turn.main.span, "task", ts, {
|
|
256
|
+
"openinference.span.kind": "TOOL",
|
|
257
|
+
"input.value": json.dumps({"subagent_type": typ, "description": str(meta.get("description") or "")})})
|
|
258
|
+
out.append({"kind": "start", "span": span})
|
|
259
|
+
task = [None, typ, span, False]
|
|
260
|
+
turn.tasks.append(task)
|
|
261
|
+
task[3] = True
|
|
262
|
+
desc = (json.loads(task[2]["attributes"].get("input.value") or "{}").get("description")
|
|
263
|
+
or meta.get("description") or f"delegate → {typ}")
|
|
264
|
+
span = self._span(turn, task[2], typ, now, {"agentglow.agent": typ, "input.value": str(desc),
|
|
265
|
+
"agentglow.claude_code.agent_id": aid})
|
|
266
|
+
out.append({"kind": "start", "span": span})
|
|
267
|
+
ag = s.agents[aid] = _Agent(typ, span, turn)
|
|
268
|
+
turn.subs[aid] = ag
|
|
269
|
+
self._start_llm(ag, now, out)
|
|
270
|
+
|
|
271
|
+
def _on_SubagentStop(self, s: _Session, p: dict, now: int, out: list) -> None:
|
|
272
|
+
aid = str(p.get("agent_id") or "")
|
|
273
|
+
ag = s.agents.pop(aid, None)
|
|
274
|
+
if ag is None:
|
|
275
|
+
return
|
|
276
|
+
text = str(p.get("last_assistant_message") or "").strip()
|
|
277
|
+
self._close_agent(ag, now, out, {"agentglow.output_text": text[:2000]} if text else {})
|
|
278
|
+
ag.turn.subs.pop(aid, None)
|
|
279
|
+
|
|
280
|
+
def _on_Stop(self, s: _Session, p: dict, now: int, out: list) -> None:
|
|
281
|
+
if s.turn and not s.turn.stopped:
|
|
282
|
+
self._stop_turn(s, s.turn, str(p.get("last_assistant_message") or "").strip(), now, out, force=False)
|
|
283
|
+
|
|
284
|
+
def _on_SessionEnd(self, s: _Session, p: dict, now: int, out: list) -> None:
|
|
285
|
+
self._close_session(s, now, out)
|
|
286
|
+
self.sessions.pop(s.id, None)
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------ helpers
|
|
289
|
+
def _span(self, turn: _Turn, parent: dict | None, name: str, now: int, attrs: dict) -> dict:
|
|
290
|
+
return {"trace_id": turn.trace_id, "span_id": _hex(8), "parent_span_id": parent["span_id"] if parent else None,
|
|
291
|
+
"name": name, "start_time_ms": now, "end_time_ms": None, "status": "unset", "attributes": attrs}
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _end(span: dict, now: int, out: list, status: str = "ok", extra: dict | None = None) -> None:
|
|
295
|
+
out.append({"kind": "end", "span": {**span, "end_time_ms": max(now, span["start_time_ms"]), "status": status,
|
|
296
|
+
"attributes": {**span["attributes"], **(extra or {})}}})
|
|
297
|
+
|
|
298
|
+
def _open_turn(self, s: _Session, prompt: str, now: int, out: list) -> _Turn:
|
|
299
|
+
s.n += 1
|
|
300
|
+
turn = s.turn = _Turn(f"{s.id}:{s.n}", _hex(16))
|
|
301
|
+
topic = " ".join(prompt.split())[:200] or "Claude Code"
|
|
302
|
+
span = self._span(turn, None, "claude", now, {
|
|
303
|
+
"agentglow.agent": "claude", "agentglow.run.id": turn.run_id, "agentglow.run.topic": topic,
|
|
304
|
+
"agentglow.run.workflow": "claude-code", "agentglow.claude_code.session": s.id})
|
|
305
|
+
out.append({"kind": "start", "span": span})
|
|
306
|
+
turn.main = _Agent("claude", span, turn)
|
|
307
|
+
self._start_llm(turn.main, now, out)
|
|
308
|
+
return turn
|
|
309
|
+
|
|
310
|
+
def _agent_for(self, s: _Session, p: dict, now: int, out: list, open_turn: bool) -> _Agent | None:
|
|
311
|
+
aid = p.get("agent_id")
|
|
312
|
+
if aid:
|
|
313
|
+
return s.agents.get(str(aid))
|
|
314
|
+
if s.turn and not s.turn.stopped:
|
|
315
|
+
return s.turn.main
|
|
316
|
+
return self._open_turn(s, "", now, out).main if open_turn else None
|
|
317
|
+
|
|
318
|
+
def _start_llm(self, ag: _Agent, now: int, out: list) -> None:
|
|
319
|
+
if ag.llm is None and not ag.closed:
|
|
320
|
+
ag.llm = self._span(ag.turn, ag.span, f"{ag.name} · thinking", now, {"openinference.span.kind": "LLM"})
|
|
321
|
+
out.append({"kind": "start", "span": ag.llm})
|
|
322
|
+
|
|
323
|
+
def _end_llm(self, ag: _Agent, now: int, out: list) -> None:
|
|
324
|
+
if ag.llm is not None:
|
|
325
|
+
self._end(ag.llm, now, out)
|
|
326
|
+
ag.llm = None
|
|
327
|
+
|
|
328
|
+
def _end_tool(self, ag: _Agent, tid: str, now: int, out: list, status: str = "ok", extra: dict | None = None) -> None:
|
|
329
|
+
span = ag.tools.pop(tid, None)
|
|
330
|
+
if span is not None:
|
|
331
|
+
self._end(span, now, out, status, extra)
|
|
332
|
+
|
|
333
|
+
def _close_agent(self, ag: _Agent, now: int, out: list, extra: dict, status: str = "ok") -> None:
|
|
334
|
+
if ag.closed:
|
|
335
|
+
return
|
|
336
|
+
self._end_llm(ag, now, out)
|
|
337
|
+
for tid in list(ag.tools):
|
|
338
|
+
self._end_tool(ag, tid, now, out, "unset")
|
|
339
|
+
ag.closed = True
|
|
340
|
+
self._end(ag.span, now, out, status, extra)
|
|
341
|
+
|
|
342
|
+
def _stop_turn(self, s: _Session, turn: _Turn, text: str, now: int, out: list, status: str = "ok",
|
|
343
|
+
force: bool = True) -> None:
|
|
344
|
+
"""End the main agent's turn: its thinking span, tools still open (denied/interrupted) and stand-in task spans.
|
|
345
|
+
With background subagents still running (and not `force`), the main agent stays open (deferred) until they
|
|
346
|
+
report back: their `<task-notification>` prompt resumes this same run; tick() closes it after GRACE_MS."""
|
|
347
|
+
turn.stopped = True
|
|
348
|
+
if not force and turn.subs and turn.main is not None:
|
|
349
|
+
turn.deferred, turn.final = True, text or turn.final
|
|
350
|
+
self._end_llm(turn.main, now, out)
|
|
351
|
+
return
|
|
352
|
+
turn.deferred = False
|
|
353
|
+
for t in turn.tasks:
|
|
354
|
+
if t[0] is None:
|
|
355
|
+
t[0] = "standin"
|
|
356
|
+
self._end(t[2], now, out, "unset")
|
|
357
|
+
if turn.main is not None:
|
|
358
|
+
extra = {"agentglow.final": text[:2000], "agentglow.output_text": text[:2000]} if text else {}
|
|
359
|
+
self._close_agent(turn.main, now, out, extra, status)
|
|
360
|
+
|
|
361
|
+
def _close_session(self, s: _Session, now: int, out: list) -> None:
|
|
362
|
+
for ag in list(s.agents.values()):
|
|
363
|
+
self._close_agent(ag, now, out, {}, "unset")
|
|
364
|
+
s.agents.clear()
|
|
365
|
+
if s.turn and (s.turn.deferred or not s.turn.stopped):
|
|
366
|
+
self._stop_turn(s, s.turn, s.turn.final, now, out, status="ok" if s.turn.deferred else "unset")
|
|
367
|
+
s.turn = None
|
agentglow/cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""`agentglow serve [--host 0.0.0.0] [--port 8100] [--falkor URL]`"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> None:
|
|
11
|
+
ap = argparse.ArgumentParser(prog="agentglow", description="Live 3D views of agent systems from OpenTelemetry spans")
|
|
12
|
+
ap.add_argument("--version", action="version", version=f"agentglow {__version__}")
|
|
13
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
14
|
+
s = sub.add_parser("serve", help="run the server + UI (one per environment)")
|
|
15
|
+
s.add_argument("--host", default=os.environ.get("AGENTGLOW_HOST", "0.0.0.0"))
|
|
16
|
+
s.add_argument("--port", type=int, default=int(os.environ.get("AGENTGLOW_PORT", "8100")))
|
|
17
|
+
s.add_argument("--falkor", default=os.environ.get("AGENTGLOW_FALKOR_URL"), help="FalkorDB URL for /live/graph, e.g. redis://localhost:6379/demo")
|
|
18
|
+
args = ap.parse_args(argv)
|
|
19
|
+
if args.cmd != "serve":
|
|
20
|
+
ap.print_help()
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
import uvicorn
|
|
24
|
+
|
|
25
|
+
from .server import create_app
|
|
26
|
+
|
|
27
|
+
shown = "localhost" if args.host in ("0.0.0.0", "::") else args.host
|
|
28
|
+
print(f"agentglow {__version__} → http://{shown}:{args.port} (spans: POST /v1/live, OTLP: /v1/traces)", flush=True)
|
|
29
|
+
uvicorn.run(create_app(falkor_url=args.falkor), host=args.host, port=args.port, log_level="warning")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
main()
|