agentglow 0.1.0__tar.gz

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.
Files changed (45) hide show
  1. agentglow-0.1.0/.gitignore +4 -0
  2. agentglow-0.1.0/PKG-INFO +110 -0
  3. agentglow-0.1.0/README.md +86 -0
  4. agentglow-0.1.0/agentglow/__init__.py +7 -0
  5. agentglow-0.1.0/agentglow/claude_code.py +367 -0
  6. agentglow-0.1.0/agentglow/cli.py +33 -0
  7. agentglow-0.1.0/agentglow/mapper.py +645 -0
  8. agentglow-0.1.0/agentglow/otel.py +131 -0
  9. agentglow-0.1.0/agentglow/server.py +263 -0
  10. agentglow-0.1.0/agentglow/state.py +72 -0
  11. agentglow-0.1.0/agentglow/static/assets/ClusterBall-DyuCTDj5.css +1 -0
  12. agentglow-0.1.0/agentglow/static/assets/ClusterBall-cE4B1CvW.js +4786 -0
  13. agentglow-0.1.0/agentglow/static/assets/Gallery-CQEYu2WV.css +1 -0
  14. agentglow-0.1.0/agentglow/static/assets/Gallery-D8dIEaZ7.js +1 -0
  15. agentglow-0.1.0/agentglow/static/assets/Grid-NYiWgi-G.js +61 -0
  16. agentglow-0.1.0/agentglow/static/assets/OrbitControls-DYjT8CYE.js +1 -0
  17. agentglow-0.1.0/agentglow/static/assets/Sparkles-kPa0rwA-.js +35 -0
  18. agentglow-0.1.0/agentglow/static/assets/Stars-TFYkdjpR.js +24 -0
  19. agentglow-0.1.0/agentglow/static/assets/Trail-Bo6rV9pc.js +114 -0
  20. agentglow-0.1.0/agentglow/static/assets/index-4nOSPMUp.js +21 -0
  21. agentglow-0.1.0/agentglow/static/assets/index-B_Ox3Guu.css +1 -0
  22. agentglow-0.1.0/agentglow/static/assets/index-BcWPDQVh.js +84 -0
  23. agentglow-0.1.0/agentglow/static/assets/index-BwnYLMbM.js +1 -0
  24. agentglow-0.1.0/agentglow/static/assets/index-Bz62ycay.js +97 -0
  25. agentglow-0.1.0/agentglow/static/assets/index-CIbdJsCm.css +1 -0
  26. agentglow-0.1.0/agentglow/static/assets/index-CR_gh-V0.js +1 -0
  27. agentglow-0.1.0/agentglow/static/assets/index-CUzplisx.js +118 -0
  28. agentglow-0.1.0/agentglow/static/assets/index-CZfkKsK0.js +159 -0
  29. agentglow-0.1.0/agentglow/static/assets/index-CaPh9M_r.js +68 -0
  30. agentglow-0.1.0/agentglow/static/assets/index-CjOTUtcY.js +100 -0
  31. agentglow-0.1.0/agentglow/static/assets/index-CmsO_P9t.js +50 -0
  32. agentglow-0.1.0/agentglow/static/assets/index-Cv8on1m7.js +226 -0
  33. agentglow-0.1.0/agentglow/static/assets/index-D59RSjRv.js +85 -0
  34. agentglow-0.1.0/agentglow/static/assets/index-D9wBYbu3.css +1 -0
  35. agentglow-0.1.0/agentglow/static/assets/index-DfT3puT3.js +47 -0
  36. agentglow-0.1.0/agentglow/static/assets/index-Dux7S8bO.js +1 -0
  37. agentglow-0.1.0/agentglow/static/assets/index-HnuICwcD.css +1 -0
  38. agentglow-0.1.0/agentglow/static/assets/index-QV2PECrF.js +140 -0
  39. agentglow-0.1.0/agentglow/static/assets/index-Xr265JCV.js +27 -0
  40. agentglow-0.1.0/agentglow/static/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  41. agentglow-0.1.0/agentglow/static/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  42. agentglow-0.1.0/agentglow/static/assets/spread-DjjdTIk9.js +1 -0
  43. agentglow-0.1.0/agentglow/static/index.html +14 -0
  44. agentglow-0.1.0/agentglow/watch.py +91 -0
  45. agentglow-0.1.0/pyproject.toml +53 -0
@@ -0,0 +1,4 @@
1
+ dist/
2
+ .venv/
3
+ __pycache__/
4
+ .pytest_cache/
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentglow
3
+ Version: 0.1.0
4
+ Summary: Live 3D views of agent systems, driven only by OpenTelemetry spans
5
+ Project-URL: Homepage, https://github.com/Nideesh1/agentglow
6
+ License: MIT
7
+ Keywords: agents,deepagents,langgraph,observability,openai-agents,opentelemetry,visualization
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: fastapi>=0.110
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: opentelemetry-proto>=1.25
12
+ Requires-Dist: opentelemetry-sdk>=1.25
13
+ Requires-Dist: protobuf>=4.21
14
+ Requires-Dist: uvicorn[standard]>=0.27
15
+ Provides-Extra: falkordb
16
+ Requires-Dist: falkordb>=1.0; extra == 'falkordb'
17
+ Provides-Extra: hatchet
18
+ Requires-Dist: hatchet-sdk[otel]>=1.0; extra == 'hatchet'
19
+ Provides-Extra: langchain
20
+ Requires-Dist: openinference-instrumentation-langchain>=0.1.30; extra == 'langchain'
21
+ Provides-Extra: openai-agents
22
+ Requires-Dist: openinference-instrumentation-openai-agents>=1.0; extra == 'openai-agents'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # AgentGlow
26
+
27
+ **Live 3D views of your agent system, driven only by OpenTelemetry.** Every agent is a glowing instance that is
28
+ born when its span starts, pulses on each LLM call, fires tool/MCP/graph packets, delegates to subagents, and fades
29
+ when its span ends. Works with LangChain, LangGraph (incl. `langgraph-supervisor`), deepagents, the OpenAI Agents SDK
30
+ (`agentglow[openai-agents]`), Hatchet workflows, Claude Code (via hooks), and any agent framework that emits OTel spans.
31
+
32
+ ![AgentGlow - neural theme](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/hero.gif)
33
+
34
+ 15 themes:
35
+
36
+ | | | |
37
+ |:-:|:-:|:-:|
38
+ | ![neural](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/neural.jpg) **neural** | ![hive](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/hive.jpg) **hive** | ![constellation](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/constellation.jpg) **constellation** |
39
+ | ![orbit](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/orbit.jpg) **orbit** | ![forest](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/forest.jpg) **forest** | ![mycelium](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/mycelium.jpg) **mycelium** |
40
+ | ![atom](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/atom.jpg) **atom** | ![airport](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/airport.jpg) **airport** | ![factory](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/factory.jpg) **factory** |
41
+ | ![city](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/city.jpg) **city** | ![ocean](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/ocean.jpg) **ocean** | ![subway](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/subway.jpg) **subway** |
42
+ | ![circuit](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/circuit.jpg) **circuit** | ![tunnel](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/tunnel.jpg) **tunnel** | ![flow](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/flow.jpg) **flow** |
43
+
44
+ ## Quickstart
45
+
46
+ ```bash
47
+ uvx agentglow serve # → http://localhost:8100 (gallery at /, scenes at /neural, /orbit, …)
48
+ uv add "agentglow[langchain]" # in your agent project (or: uv pip install "agentglow[langchain]")
49
+ # pip install "agentglow[langchain]" also works
50
+ ```
51
+
52
+ ```python
53
+ import agentglow
54
+ agentglow.watch() # call once, before your agents run
55
+
56
+ # ... run your LangGraph / deepagents / Hatchet code as usual
57
+ ```
58
+
59
+ That's it. `watch()` reuses your global OpenTelemetry `TracerProvider` (Langfuse or other exporters keep working),
60
+ or installs one, adds a `LiveSpanProcessor` that streams span **starts and ends** to the server in ~50 ms batches
61
+ from a background thread (never blocks, drops silently if the server is down), and turns on OpenInference
62
+ LangChain instrumentation and Hatchet instrumentation when those packages are installed.
63
+
64
+ ## What gets drawn
65
+
66
+ | Span | Becomes |
67
+ |---|---|
68
+ | LangGraph / deepagents agent graph (span named after `create_deep_agent(name=...)`) | an agent instance |
69
+ | deepagents subagent (runs under the `task` tool) | a smaller child instance, with delegation + result messages |
70
+ | LLM span (OpenInference `LLM`, `gen_ai.operation.name=chat`) | "thinking" + a pulse sized by tokens |
71
+ | Tool span | a tool event on the owning agent |
72
+ | `mcp.server.name` / `agentglow.mcp.server` (+ `agentglow.mcp.resource`, `agentglow.mcp.resource_kind`) | an MCP satellite with a live tether while the call is pending |
73
+ | `db.system` (+ `agentglow.graph.nodes`, `agentglow.db.op`) | graph read/write flares |
74
+ | Hatchet step run (`hatchet.workflow_run_id`, `hatchet.step_name`) or `agentglow.step` | run lanes and steps |
75
+
76
+ Optional attributes you can set on your own spans: `agentglow.agent` (mark a span as an agent, value = name),
77
+ `agentglow.run.id`, `agentglow.run.topic`, `agentglow.step`, `agentglow.final` (final answer text).
78
+
79
+ Announce MCP servers before they are called: `agentglow.register_mcp("analytics", {"snowflake": "warehouse", "spark": "spark"})`.
80
+
81
+ ## Options
82
+
83
+ ```
84
+ agentglow serve [--host 0.0.0.0] [--port 8100] [--falkor redis://localhost:6379/<graph>]
85
+ ```
86
+
87
+ | | |
88
+ |---|---|
89
+ | `agentglow.watch(url="http://localhost:8100", *, instrument=True, service_name=None)` | `url` also from `AGENTGLOW_URL` |
90
+ | `POST /v1/live` | span start/end batches from `watch()` |
91
+ | `POST /v1/traces` | standard OTLP/HTTP (protobuf or JSON) - point any OTel SDK or Collector here (ended spans only) |
92
+ | `GET /live/stream` | SSE world events; new viewers get MCP topology + runs still in progress |
93
+ | `GET /live/graph` | graph sample for the scenes from FalkorDB (`--falkor` / `AGENTGLOW_FALKOR_URL`), else an empty graph |
94
+ | `POST /v1/claude-code` | Claude Code HTTP hooks → its main agent + subagents in 3D (see `examples/claude-code`) |
95
+ | `GET /live/health` | status |
96
+ | `POST /live/run` `{topic}` | optional: forwards to `AGENTGLOW_RUN_WEBHOOK` (your trigger endpoint) and returns its JSON, e.g. `{run_id}`; health reports `run: true` and the UI shows "▶ Run agents" only when it is set |
97
+
98
+ Embed in your own React app: `npm i agentglow` → `<AgentScene theme="neural" source="http://localhost:8100" />`.
99
+
100
+ ## Scale
101
+
102
+ Above 12 live agents the scenes auto-group older runs into clickable clusters and keep the newest ~10 in full
103
+ detail (~60 fps with 500 live agents).
104
+
105
+ ## Deploying
106
+
107
+ State is in memory (no Redis). Run **exactly one** server per environment - on Kubernetes a Deployment with
108
+ `replicas: 1` plus a Service; point every worker's `AGENTGLOW_URL` at that Service.
109
+
110
+ MIT licensed · https://github.com/Nideesh1/agentglow
@@ -0,0 +1,86 @@
1
+ # AgentGlow
2
+
3
+ **Live 3D views of your agent system, driven only by OpenTelemetry.** Every agent is a glowing instance that is
4
+ born when its span starts, pulses on each LLM call, fires tool/MCP/graph packets, delegates to subagents, and fades
5
+ when its span ends. Works with LangChain, LangGraph (incl. `langgraph-supervisor`), deepagents, the OpenAI Agents SDK
6
+ (`agentglow[openai-agents]`), Hatchet workflows, Claude Code (via hooks), and any agent framework that emits OTel spans.
7
+
8
+ ![AgentGlow - neural theme](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/hero.gif)
9
+
10
+ 15 themes:
11
+
12
+ | | | |
13
+ |:-:|:-:|:-:|
14
+ | ![neural](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/neural.jpg) **neural** | ![hive](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/hive.jpg) **hive** | ![constellation](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/constellation.jpg) **constellation** |
15
+ | ![orbit](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/orbit.jpg) **orbit** | ![forest](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/forest.jpg) **forest** | ![mycelium](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/mycelium.jpg) **mycelium** |
16
+ | ![atom](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/atom.jpg) **atom** | ![airport](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/airport.jpg) **airport** | ![factory](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/factory.jpg) **factory** |
17
+ | ![city](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/city.jpg) **city** | ![ocean](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/ocean.jpg) **ocean** | ![subway](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/subway.jpg) **subway** |
18
+ | ![circuit](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/circuit.jpg) **circuit** | ![tunnel](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/tunnel.jpg) **tunnel** | ![flow](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/flow.jpg) **flow** |
19
+
20
+ ## Quickstart
21
+
22
+ ```bash
23
+ uvx agentglow serve # → http://localhost:8100 (gallery at /, scenes at /neural, /orbit, …)
24
+ uv add "agentglow[langchain]" # in your agent project (or: uv pip install "agentglow[langchain]")
25
+ # pip install "agentglow[langchain]" also works
26
+ ```
27
+
28
+ ```python
29
+ import agentglow
30
+ agentglow.watch() # call once, before your agents run
31
+
32
+ # ... run your LangGraph / deepagents / Hatchet code as usual
33
+ ```
34
+
35
+ That's it. `watch()` reuses your global OpenTelemetry `TracerProvider` (Langfuse or other exporters keep working),
36
+ or installs one, adds a `LiveSpanProcessor` that streams span **starts and ends** to the server in ~50 ms batches
37
+ from a background thread (never blocks, drops silently if the server is down), and turns on OpenInference
38
+ LangChain instrumentation and Hatchet instrumentation when those packages are installed.
39
+
40
+ ## What gets drawn
41
+
42
+ | Span | Becomes |
43
+ |---|---|
44
+ | LangGraph / deepagents agent graph (span named after `create_deep_agent(name=...)`) | an agent instance |
45
+ | deepagents subagent (runs under the `task` tool) | a smaller child instance, with delegation + result messages |
46
+ | LLM span (OpenInference `LLM`, `gen_ai.operation.name=chat`) | "thinking" + a pulse sized by tokens |
47
+ | Tool span | a tool event on the owning agent |
48
+ | `mcp.server.name` / `agentglow.mcp.server` (+ `agentglow.mcp.resource`, `agentglow.mcp.resource_kind`) | an MCP satellite with a live tether while the call is pending |
49
+ | `db.system` (+ `agentglow.graph.nodes`, `agentglow.db.op`) | graph read/write flares |
50
+ | Hatchet step run (`hatchet.workflow_run_id`, `hatchet.step_name`) or `agentglow.step` | run lanes and steps |
51
+
52
+ Optional attributes you can set on your own spans: `agentglow.agent` (mark a span as an agent, value = name),
53
+ `agentglow.run.id`, `agentglow.run.topic`, `agentglow.step`, `agentglow.final` (final answer text).
54
+
55
+ Announce MCP servers before they are called: `agentglow.register_mcp("analytics", {"snowflake": "warehouse", "spark": "spark"})`.
56
+
57
+ ## Options
58
+
59
+ ```
60
+ agentglow serve [--host 0.0.0.0] [--port 8100] [--falkor redis://localhost:6379/<graph>]
61
+ ```
62
+
63
+ | | |
64
+ |---|---|
65
+ | `agentglow.watch(url="http://localhost:8100", *, instrument=True, service_name=None)` | `url` also from `AGENTGLOW_URL` |
66
+ | `POST /v1/live` | span start/end batches from `watch()` |
67
+ | `POST /v1/traces` | standard OTLP/HTTP (protobuf or JSON) - point any OTel SDK or Collector here (ended spans only) |
68
+ | `GET /live/stream` | SSE world events; new viewers get MCP topology + runs still in progress |
69
+ | `GET /live/graph` | graph sample for the scenes from FalkorDB (`--falkor` / `AGENTGLOW_FALKOR_URL`), else an empty graph |
70
+ | `POST /v1/claude-code` | Claude Code HTTP hooks → its main agent + subagents in 3D (see `examples/claude-code`) |
71
+ | `GET /live/health` | status |
72
+ | `POST /live/run` `{topic}` | optional: forwards to `AGENTGLOW_RUN_WEBHOOK` (your trigger endpoint) and returns its JSON, e.g. `{run_id}`; health reports `run: true` and the UI shows "▶ Run agents" only when it is set |
73
+
74
+ Embed in your own React app: `npm i agentglow` → `<AgentScene theme="neural" source="http://localhost:8100" />`.
75
+
76
+ ## Scale
77
+
78
+ Above 12 live agents the scenes auto-group older runs into clickable clusters and keep the newest ~10 in full
79
+ detail (~60 fps with 500 live agents).
80
+
81
+ ## Deploying
82
+
83
+ State is in memory (no Redis). Run **exactly one** server per environment - on Kubernetes a Deployment with
84
+ `replicas: 1` plus a Service; point every worker's `AGENTGLOW_URL` at that Service.
85
+
86
+ MIT licensed · https://github.com/Nideesh1/agentglow
@@ -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__"]
@@ -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
@@ -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()