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.
- agentglow-0.1.0/.gitignore +4 -0
- agentglow-0.1.0/PKG-INFO +110 -0
- agentglow-0.1.0/README.md +86 -0
- agentglow-0.1.0/agentglow/__init__.py +7 -0
- agentglow-0.1.0/agentglow/claude_code.py +367 -0
- agentglow-0.1.0/agentglow/cli.py +33 -0
- agentglow-0.1.0/agentglow/mapper.py +645 -0
- agentglow-0.1.0/agentglow/otel.py +131 -0
- agentglow-0.1.0/agentglow/server.py +263 -0
- agentglow-0.1.0/agentglow/state.py +72 -0
- agentglow-0.1.0/agentglow/static/assets/ClusterBall-DyuCTDj5.css +1 -0
- agentglow-0.1.0/agentglow/static/assets/ClusterBall-cE4B1CvW.js +4786 -0
- agentglow-0.1.0/agentglow/static/assets/Gallery-CQEYu2WV.css +1 -0
- agentglow-0.1.0/agentglow/static/assets/Gallery-D8dIEaZ7.js +1 -0
- agentglow-0.1.0/agentglow/static/assets/Grid-NYiWgi-G.js +61 -0
- agentglow-0.1.0/agentglow/static/assets/OrbitControls-DYjT8CYE.js +1 -0
- agentglow-0.1.0/agentglow/static/assets/Sparkles-kPa0rwA-.js +35 -0
- agentglow-0.1.0/agentglow/static/assets/Stars-TFYkdjpR.js +24 -0
- agentglow-0.1.0/agentglow/static/assets/Trail-Bo6rV9pc.js +114 -0
- agentglow-0.1.0/agentglow/static/assets/index-4nOSPMUp.js +21 -0
- agentglow-0.1.0/agentglow/static/assets/index-B_Ox3Guu.css +1 -0
- agentglow-0.1.0/agentglow/static/assets/index-BcWPDQVh.js +84 -0
- agentglow-0.1.0/agentglow/static/assets/index-BwnYLMbM.js +1 -0
- agentglow-0.1.0/agentglow/static/assets/index-Bz62ycay.js +97 -0
- agentglow-0.1.0/agentglow/static/assets/index-CIbdJsCm.css +1 -0
- agentglow-0.1.0/agentglow/static/assets/index-CR_gh-V0.js +1 -0
- agentglow-0.1.0/agentglow/static/assets/index-CUzplisx.js +118 -0
- agentglow-0.1.0/agentglow/static/assets/index-CZfkKsK0.js +159 -0
- agentglow-0.1.0/agentglow/static/assets/index-CaPh9M_r.js +68 -0
- agentglow-0.1.0/agentglow/static/assets/index-CjOTUtcY.js +100 -0
- agentglow-0.1.0/agentglow/static/assets/index-CmsO_P9t.js +50 -0
- agentglow-0.1.0/agentglow/static/assets/index-Cv8on1m7.js +226 -0
- agentglow-0.1.0/agentglow/static/assets/index-D59RSjRv.js +85 -0
- agentglow-0.1.0/agentglow/static/assets/index-D9wBYbu3.css +1 -0
- agentglow-0.1.0/agentglow/static/assets/index-DfT3puT3.js +47 -0
- agentglow-0.1.0/agentglow/static/assets/index-Dux7S8bO.js +1 -0
- agentglow-0.1.0/agentglow/static/assets/index-HnuICwcD.css +1 -0
- agentglow-0.1.0/agentglow/static/assets/index-QV2PECrF.js +140 -0
- agentglow-0.1.0/agentglow/static/assets/index-Xr265JCV.js +27 -0
- agentglow-0.1.0/agentglow/static/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- agentglow-0.1.0/agentglow/static/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- agentglow-0.1.0/agentglow/static/assets/spread-DjjdTIk9.js +1 -0
- agentglow-0.1.0/agentglow/static/index.html +14 -0
- agentglow-0.1.0/agentglow/watch.py +91 -0
- agentglow-0.1.0/pyproject.toml +53 -0
agentglow-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
33
|
+
|
|
34
|
+
15 themes:
|
|
35
|
+
|
|
36
|
+
| | | |
|
|
37
|
+
|:-:|:-:|:-:|
|
|
38
|
+
|  **neural** |  **hive** |  **constellation** |
|
|
39
|
+
|  **orbit** |  **forest** |  **mycelium** |
|
|
40
|
+
|  **atom** |  **airport** |  **factory** |
|
|
41
|
+
|  **city** |  **ocean** |  **subway** |
|
|
42
|
+
|  **circuit** |  **tunnel** |  **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
|
+

|
|
9
|
+
|
|
10
|
+
15 themes:
|
|
11
|
+
|
|
12
|
+
| | | |
|
|
13
|
+
|:-:|:-:|:-:|
|
|
14
|
+
|  **neural** |  **hive** |  **constellation** |
|
|
15
|
+
|  **orbit** |  **forest** |  **mycelium** |
|
|
16
|
+
|  **atom** |  **airport** |  **factory** |
|
|
17
|
+
|  **city** |  **ocean** |  **subway** |
|
|
18
|
+
|  **circuit** |  **tunnel** |  **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()
|