htmlgraph 0.26.24__py3-none-any.whl → 0.26.25__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.
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/main.py +56 -23
- htmlgraph/api/templates/dashboard-redesign.html +3 -3
- htmlgraph/api/templates/dashboard.html +3 -3
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/builders/track.py +26 -0
- htmlgraph/cli/base.py +31 -7
- htmlgraph/cli/work/__init__.py +74 -0
- htmlgraph/cli/work/browse.py +114 -0
- htmlgraph/cli/work/snapshot.py +558 -0
- htmlgraph/collections/base.py +34 -0
- htmlgraph/collections/todo.py +12 -0
- htmlgraph/converter.py +11 -0
- htmlgraph/hooks/orchestrator.py +88 -14
- htmlgraph/hooks/session_handler.py +3 -1
- htmlgraph/models.py +18 -1
- htmlgraph/orchestration/__init__.py +4 -0
- htmlgraph/orchestration/plugin_manager.py +1 -2
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/refs.py +343 -0
- htmlgraph/sdk.py +71 -1
- htmlgraph/session_manager.py +1 -7
- htmlgraph/sessions/handoff.py +6 -0
- htmlgraph/track_builder.py +12 -0
- {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/METADATA +1 -1
- {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/RECORD +33 -28
- {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.26.24.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/WHEEL +0 -0
- {htmlgraph-0.26.24.dist-info → htmlgraph-0.26.25.dist-info}/entry_points.txt +0 -0
htmlgraph/hooks/orchestrator.py
CHANGED
|
@@ -94,6 +94,73 @@ def load_tool_history(session_id: str) -> list[dict]:
|
|
|
94
94
|
return []
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
def record_tool_event(tool_name: str, session_id: str) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Record a tool event to the database for history tracking.
|
|
100
|
+
|
|
101
|
+
This is called at the end of PreToolUse hook execution to track
|
|
102
|
+
tool usage patterns for sequence detection.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
tool_name: Name of the tool being called
|
|
106
|
+
session_id: Session identifier for isolation
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
import datetime
|
|
110
|
+
import uuid
|
|
111
|
+
|
|
112
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
113
|
+
|
|
114
|
+
# Find database path
|
|
115
|
+
cwd = Path.cwd()
|
|
116
|
+
graph_dir = cwd / ".htmlgraph"
|
|
117
|
+
if not graph_dir.exists():
|
|
118
|
+
for parent in [cwd.parent, cwd.parent.parent, cwd.parent.parent.parent]:
|
|
119
|
+
candidate = parent / ".htmlgraph"
|
|
120
|
+
if candidate.exists():
|
|
121
|
+
graph_dir = candidate
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
if not graph_dir.exists():
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
db_path = graph_dir / "htmlgraph.db"
|
|
128
|
+
db = HtmlGraphDB(str(db_path))
|
|
129
|
+
if db.connection is None:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
cursor = db.connection.cursor()
|
|
133
|
+
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
134
|
+
|
|
135
|
+
# Ensure session exists (required by FK constraint)
|
|
136
|
+
cursor.execute(
|
|
137
|
+
"""
|
|
138
|
+
INSERT OR IGNORE INTO sessions (session_id, agent_assigned, created_at, status)
|
|
139
|
+
VALUES (?, ?, ?, ?)
|
|
140
|
+
""",
|
|
141
|
+
(session_id, "orchestrator-hook", timestamp, "active"),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Record the tool event using the actual schema
|
|
145
|
+
# Schema has: event_id, agent_id, event_type, timestamp, tool_name, session_id, etc.
|
|
146
|
+
event_id = str(uuid.uuid4())
|
|
147
|
+
agent_id = "orchestrator-hook" # Identifier for the hook
|
|
148
|
+
|
|
149
|
+
cursor.execute(
|
|
150
|
+
"""
|
|
151
|
+
INSERT INTO agent_events (event_id, agent_id, event_type, timestamp, tool_name, session_id)
|
|
152
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
153
|
+
""",
|
|
154
|
+
(event_id, agent_id, "tool_call", timestamp, tool_name, session_id),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
db.connection.commit()
|
|
158
|
+
db.disconnect()
|
|
159
|
+
except Exception:
|
|
160
|
+
# Graceful degradation - don't fail hook on recording error
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
97
164
|
def is_allowed_orchestrator_operation(
|
|
98
165
|
tool: str, params: dict[str, Any], session_id: str = "unknown"
|
|
99
166
|
) -> tuple[bool, str, str]:
|
|
@@ -402,6 +469,7 @@ def enforce_orchestrator_mode(
|
|
|
402
469
|
# Check if this is a subagent context - subagents have unrestricted tool access
|
|
403
470
|
if is_subagent_context():
|
|
404
471
|
return {
|
|
472
|
+
"continue": True,
|
|
405
473
|
"hookSpecificOutput": {
|
|
406
474
|
"hookEventName": "PreToolUse",
|
|
407
475
|
"permissionDecision": "allow",
|
|
@@ -425,18 +493,14 @@ def enforce_orchestrator_mode(
|
|
|
425
493
|
manager = OrchestratorModeManager(graph_dir)
|
|
426
494
|
|
|
427
495
|
if not manager.is_enabled():
|
|
428
|
-
# Mode not active, allow everything
|
|
429
|
-
return {
|
|
430
|
-
"hookSpecificOutput": {
|
|
431
|
-
"hookEventName": "PreToolUse",
|
|
432
|
-
"permissionDecision": "allow",
|
|
433
|
-
},
|
|
434
|
-
}
|
|
496
|
+
# Mode not active, allow everything with no additional output
|
|
497
|
+
return {"continue": True}
|
|
435
498
|
|
|
436
499
|
enforcement_level = manager.get_enforcement_level()
|
|
437
500
|
except Exception:
|
|
438
501
|
# If we can't check mode, fail open (allow)
|
|
439
502
|
return {
|
|
503
|
+
"continue": True,
|
|
440
504
|
"hookSpecificOutput": {
|
|
441
505
|
"hookEventName": "PreToolUse",
|
|
442
506
|
"permissionDecision": "allow",
|
|
@@ -467,6 +531,7 @@ def enforce_orchestrator_mode(
|
|
|
467
531
|
)
|
|
468
532
|
|
|
469
533
|
return {
|
|
534
|
+
"continue": False,
|
|
470
535
|
"hookSpecificOutput": {
|
|
471
536
|
"hookEventName": "PreToolUse",
|
|
472
537
|
"permissionDecision": "deny",
|
|
@@ -491,6 +556,7 @@ def enforce_orchestrator_mode(
|
|
|
491
556
|
):
|
|
492
557
|
# Provide guidance even when allowing
|
|
493
558
|
return {
|
|
559
|
+
"continue": True,
|
|
494
560
|
"hookSpecificOutput": {
|
|
495
561
|
"hookEventName": "PreToolUse",
|
|
496
562
|
"permissionDecision": "allow",
|
|
@@ -498,6 +564,7 @@ def enforce_orchestrator_mode(
|
|
|
498
564
|
},
|
|
499
565
|
}
|
|
500
566
|
return {
|
|
567
|
+
"continue": True,
|
|
501
568
|
"hookSpecificOutput": {
|
|
502
569
|
"hookEventName": "PreToolUse",
|
|
503
570
|
"permissionDecision": "allow",
|
|
@@ -513,8 +580,8 @@ def enforce_orchestrator_mode(
|
|
|
513
580
|
suggestion = create_task_suggestion(tool, params)
|
|
514
581
|
|
|
515
582
|
if enforcement_level == "strict":
|
|
516
|
-
# STRICT mode -
|
|
517
|
-
|
|
583
|
+
# STRICT mode - advisory warning with violation count (does not block)
|
|
584
|
+
warning_message = (
|
|
518
585
|
f"🚫 ORCHESTRATOR MODE VIOLATION ({violations}/{circuit_breaker_threshold}): {reason}\n\n"
|
|
519
586
|
f"⚠️ WARNING: Direct operations waste context and break delegation pattern!\n\n"
|
|
520
587
|
f"Suggested delegation:\n"
|
|
@@ -523,23 +590,25 @@ def enforce_orchestrator_mode(
|
|
|
523
590
|
|
|
524
591
|
# Add circuit breaker warning if approaching threshold
|
|
525
592
|
if violations >= circuit_breaker_threshold:
|
|
526
|
-
|
|
593
|
+
warning_message += (
|
|
527
594
|
"🚨 CIRCUIT BREAKER TRIGGERED - Further violations will be blocked!\n\n"
|
|
528
595
|
"Reset with: uv run htmlgraph orchestrator reset-violations\n"
|
|
529
596
|
)
|
|
530
597
|
elif violations == circuit_breaker_threshold - 1:
|
|
531
|
-
|
|
598
|
+
warning_message += "⚠️ Next violation will trigger circuit breaker!\n\n"
|
|
532
599
|
|
|
533
|
-
|
|
600
|
+
warning_message += (
|
|
534
601
|
"See ORCHESTRATOR_DIRECTIVES in session context for HtmlGraph delegation pattern.\n"
|
|
535
602
|
"To disable orchestrator mode: uv run htmlgraph orchestrator disable"
|
|
536
603
|
)
|
|
537
604
|
|
|
605
|
+
# Advisory-only: allow operation but provide warning
|
|
538
606
|
return {
|
|
607
|
+
"continue": True,
|
|
539
608
|
"hookSpecificOutput": {
|
|
540
609
|
"hookEventName": "PreToolUse",
|
|
541
|
-
"permissionDecision": "
|
|
542
|
-
"
|
|
610
|
+
"permissionDecision": "allow",
|
|
611
|
+
"additionalContext": warning_message,
|
|
543
612
|
},
|
|
544
613
|
}
|
|
545
614
|
else:
|
|
@@ -549,6 +618,7 @@ def enforce_orchestrator_mode(
|
|
|
549
618
|
)
|
|
550
619
|
|
|
551
620
|
return {
|
|
621
|
+
"continue": True,
|
|
552
622
|
"hookSpecificOutput": {
|
|
553
623
|
"hookEventName": "PreToolUse",
|
|
554
624
|
"permissionDecision": "allow",
|
|
@@ -592,5 +662,9 @@ def main() -> None:
|
|
|
592
662
|
# Enforce orchestrator mode with session_id for history lookup
|
|
593
663
|
response = enforce_orchestrator_mode(tool_name, tool_input, session_id)
|
|
594
664
|
|
|
665
|
+
# Record tool event to database for history tracking
|
|
666
|
+
# This allows subsequent calls to detect patterns (e.g., multiple Reads)
|
|
667
|
+
record_tool_event(tool_name, session_id)
|
|
668
|
+
|
|
595
669
|
# Output JSON response
|
|
596
670
|
print(json.dumps(response))
|
|
@@ -185,7 +185,9 @@ def handle_session_start(context: HookContext, session: Any | None) -> dict[str,
|
|
|
185
185
|
|
|
186
186
|
{feature_list}
|
|
187
187
|
|
|
188
|
-
Activity will be attributed to these features based on file patterns and keywords.
|
|
188
|
+
Activity will be attributed to these features based on file patterns and keywords.
|
|
189
|
+
|
|
190
|
+
**To view all work and progress:** `htmlgraph snapshot --summary`"""
|
|
189
191
|
output["hookSpecificOutput"]["sessionFeatureContext"] = context_str
|
|
190
192
|
context.log("info", f"Loaded {len(active_features)} active features")
|
|
191
193
|
|
htmlgraph/models.py
CHANGED
|
@@ -1385,7 +1385,12 @@ class Session(BaseModel):
|
|
|
1385
1385
|
|
|
1386
1386
|
# Build handoff HTML
|
|
1387
1387
|
handoff_html = ""
|
|
1388
|
-
if
|
|
1388
|
+
if (
|
|
1389
|
+
self.handoff_notes
|
|
1390
|
+
or self.recommended_next
|
|
1391
|
+
or self.blockers
|
|
1392
|
+
or self.recommended_context
|
|
1393
|
+
):
|
|
1389
1394
|
handoff_section = """
|
|
1390
1395
|
<section data-handoff>
|
|
1391
1396
|
<h3>Handoff Context</h3>"""
|
|
@@ -1408,6 +1413,18 @@ class Session(BaseModel):
|
|
|
1408
1413
|
</ul>
|
|
1409
1414
|
</div>"""
|
|
1410
1415
|
|
|
1416
|
+
if self.recommended_context:
|
|
1417
|
+
context_items = "\n ".join(
|
|
1418
|
+
f"<li>{file_path}</li>" for file_path in self.recommended_context
|
|
1419
|
+
)
|
|
1420
|
+
handoff_section += f"""
|
|
1421
|
+
<div data-recommended-context>
|
|
1422
|
+
<strong>Recommended Context:</strong>
|
|
1423
|
+
<ul>
|
|
1424
|
+
{context_items}
|
|
1425
|
+
</ul>
|
|
1426
|
+
</div>"""
|
|
1427
|
+
|
|
1411
1428
|
handoff_section += "\n </section>"
|
|
1412
1429
|
handoff_html = handoff_section
|
|
1413
1430
|
|
|
@@ -9,6 +9,7 @@ from .model_selection import (
|
|
|
9
9
|
get_fallback_chain,
|
|
10
10
|
select_model,
|
|
11
11
|
)
|
|
12
|
+
from .spawner_event_tracker import SpawnerEventTracker, create_tracker_from_env
|
|
12
13
|
|
|
13
14
|
# Export modular spawners for advanced usage
|
|
14
15
|
from .spawners import (
|
|
@@ -37,6 +38,9 @@ __all__ = [
|
|
|
37
38
|
"CodexSpawner",
|
|
38
39
|
"CopilotSpawner",
|
|
39
40
|
"ClaudeSpawner",
|
|
41
|
+
# Spawner event tracking
|
|
42
|
+
"SpawnerEventTracker",
|
|
43
|
+
"create_tracker_from_env",
|
|
40
44
|
# Model selection
|
|
41
45
|
"ModelSelection",
|
|
42
46
|
"TaskType",
|
|
@@ -22,7 +22,7 @@ class PluginManager:
|
|
|
22
22
|
"""Get the plugin directory path.
|
|
23
23
|
|
|
24
24
|
Returns:
|
|
25
|
-
Path to packages/claude-plugin
|
|
25
|
+
Path to packages/claude-plugin (the plugin root, not .claude-plugin)
|
|
26
26
|
"""
|
|
27
27
|
# Path(__file__) = .../src/python/htmlgraph/orchestration/plugin_manager.py
|
|
28
28
|
# Need to go up 5 levels to reach project root
|
|
@@ -30,7 +30,6 @@ class PluginManager:
|
|
|
30
30
|
Path(__file__).parent.parent.parent.parent.parent
|
|
31
31
|
/ "packages"
|
|
32
32
|
/ "claude-plugin"
|
|
33
|
-
/ ".claude-plugin"
|
|
34
33
|
)
|
|
35
34
|
|
|
36
35
|
@staticmethod
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Spawner event tracking helper for internal activity tracking in spawned sessions.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for tracking internal activities within spawner agents
|
|
4
|
+
and linking them to parent delegation events for observability in HtmlGraph.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from htmlgraph.orchestration.spawner_event_tracker import SpawnerEventTracker
|
|
8
|
+
|
|
9
|
+
tracker = SpawnerEventTracker(
|
|
10
|
+
delegation_event_id="event-abc123",
|
|
11
|
+
parent_agent="orchestrator",
|
|
12
|
+
spawner_type="gemini"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Track initialization phase
|
|
16
|
+
init_event = tracker.record_phase("Initializing Spawner", spawned_agent="gemini-2.0-flash")
|
|
17
|
+
|
|
18
|
+
# Track execution phase
|
|
19
|
+
exec_event = tracker.record_phase("Executing Gemini", tool_name="gemini-cli")
|
|
20
|
+
tracker.complete_phase(exec_event["event_id"], output_summary="Generated output...")
|
|
21
|
+
|
|
22
|
+
# Track completion
|
|
23
|
+
tracker.record_completion(success=True, response="Result here")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SpawnerEventTracker:
|
|
33
|
+
"""Track internal activities in spawner agents with parent-child linking."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
delegation_event_id: str | None = None,
|
|
38
|
+
parent_agent: str = "orchestrator",
|
|
39
|
+
spawner_type: str = "generic",
|
|
40
|
+
session_id: str | None = None,
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
Initialize spawner event tracker.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
delegation_event_id: Parent delegation event ID to link to
|
|
47
|
+
parent_agent: Agent that initiated the spawning
|
|
48
|
+
spawner_type: Type of spawner (gemini, codex, copilot)
|
|
49
|
+
session_id: Session ID for events
|
|
50
|
+
"""
|
|
51
|
+
self.delegation_event_id = delegation_event_id
|
|
52
|
+
self.parent_agent = parent_agent
|
|
53
|
+
self.spawner_type = spawner_type
|
|
54
|
+
self.session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
|
|
55
|
+
self.db = None
|
|
56
|
+
self.phase_events: dict[str, dict[str, Any]] = {}
|
|
57
|
+
self.start_time = time.time()
|
|
58
|
+
|
|
59
|
+
# Try to initialize database for event tracking
|
|
60
|
+
try:
|
|
61
|
+
from htmlgraph.config import get_database_path
|
|
62
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
63
|
+
|
|
64
|
+
# Get correct database path from environment or project root
|
|
65
|
+
db_path = get_database_path()
|
|
66
|
+
|
|
67
|
+
if db_path.exists():
|
|
68
|
+
self.db = HtmlGraphDB(str(db_path))
|
|
69
|
+
except Exception:
|
|
70
|
+
# Tracking is optional, continue without it
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def record_phase(
|
|
74
|
+
self,
|
|
75
|
+
phase_name: str,
|
|
76
|
+
spawned_agent: str | None = None,
|
|
77
|
+
tool_name: str | None = None,
|
|
78
|
+
input_summary: str | None = None,
|
|
79
|
+
status: str = "running",
|
|
80
|
+
parent_phase_event_id: str | None = None,
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
"""
|
|
83
|
+
Record the start of a phase in spawner execution.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
phase_name: Human-readable phase name (e.g., "Initializing Spawner")
|
|
87
|
+
spawned_agent: Agent being spawned (optional)
|
|
88
|
+
tool_name: Tool being executed (optional)
|
|
89
|
+
input_summary: Summary of input (optional)
|
|
90
|
+
status: Current status (running, completed, failed)
|
|
91
|
+
parent_phase_event_id: Parent phase event ID for proper nesting (optional)
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Event dictionary with event_id and metadata
|
|
95
|
+
"""
|
|
96
|
+
if not self.db:
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
event_id = f"event-{uuid.uuid4().hex[:8]}"
|
|
100
|
+
event_type = "tool_call"
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
context = {
|
|
104
|
+
"phase_name": phase_name,
|
|
105
|
+
"spawner_type": self.spawner_type,
|
|
106
|
+
"parent_delegation_event": self.delegation_event_id,
|
|
107
|
+
}
|
|
108
|
+
if spawned_agent:
|
|
109
|
+
context["spawned_agent"] = spawned_agent
|
|
110
|
+
if tool_name:
|
|
111
|
+
context["tool"] = tool_name
|
|
112
|
+
|
|
113
|
+
# Use parent_phase_event_id if provided, otherwise use delegation_event_id
|
|
114
|
+
actual_parent_event_id = parent_phase_event_id or self.delegation_event_id
|
|
115
|
+
|
|
116
|
+
self.db.insert_event(
|
|
117
|
+
event_id=event_id,
|
|
118
|
+
agent_id=spawned_agent or self.parent_agent,
|
|
119
|
+
event_type=event_type,
|
|
120
|
+
session_id=self.session_id,
|
|
121
|
+
tool_name=tool_name
|
|
122
|
+
or f"HeadlessSpawner.{phase_name.replace(' ', '_').lower()}",
|
|
123
|
+
input_summary=input_summary or phase_name,
|
|
124
|
+
context=context,
|
|
125
|
+
parent_event_id=actual_parent_event_id,
|
|
126
|
+
subagent_type=self.spawner_type,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
event = {
|
|
130
|
+
"event_id": event_id,
|
|
131
|
+
"phase_name": phase_name,
|
|
132
|
+
"spawned_agent": spawned_agent,
|
|
133
|
+
"tool_name": tool_name,
|
|
134
|
+
"status": status,
|
|
135
|
+
"start_time": time.time(),
|
|
136
|
+
}
|
|
137
|
+
self.phase_events[event_id] = event
|
|
138
|
+
return event
|
|
139
|
+
|
|
140
|
+
except Exception:
|
|
141
|
+
# Non-fatal - tracking is best-effort
|
|
142
|
+
return {}
|
|
143
|
+
|
|
144
|
+
def complete_phase(
|
|
145
|
+
self,
|
|
146
|
+
event_id: str,
|
|
147
|
+
output_summary: str | None = None,
|
|
148
|
+
status: str = "completed",
|
|
149
|
+
execution_duration: float | None = None,
|
|
150
|
+
) -> bool:
|
|
151
|
+
"""
|
|
152
|
+
Mark a phase as completed with results.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
event_id: Event ID from record_phase
|
|
156
|
+
output_summary: Summary of output/result
|
|
157
|
+
status: Final status (completed, failed)
|
|
158
|
+
execution_duration: Execution time in seconds (auto-calculated if not provided)
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True if update successful, False otherwise
|
|
162
|
+
"""
|
|
163
|
+
if not self.db or not event_id:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
if execution_duration is None and event_id in self.phase_events:
|
|
168
|
+
execution_duration = (
|
|
169
|
+
time.time() - self.phase_events[event_id]["start_time"]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if self.db.connection is None:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
cursor = self.db.connection.cursor()
|
|
176
|
+
cursor.execute(
|
|
177
|
+
"""
|
|
178
|
+
UPDATE agent_events
|
|
179
|
+
SET output_summary = ?, status = ?, execution_duration_seconds = ?,
|
|
180
|
+
updated_at = CURRENT_TIMESTAMP
|
|
181
|
+
WHERE event_id = ?
|
|
182
|
+
""",
|
|
183
|
+
(output_summary, status, execution_duration or 0.0, event_id),
|
|
184
|
+
)
|
|
185
|
+
self.db.connection.commit()
|
|
186
|
+
|
|
187
|
+
if event_id in self.phase_events:
|
|
188
|
+
self.phase_events[event_id]["status"] = status
|
|
189
|
+
self.phase_events[event_id]["output_summary"] = output_summary
|
|
190
|
+
|
|
191
|
+
return True
|
|
192
|
+
except Exception:
|
|
193
|
+
# Non-fatal
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
def record_completion(
|
|
197
|
+
self,
|
|
198
|
+
success: bool,
|
|
199
|
+
response: str | None = None,
|
|
200
|
+
error: str | None = None,
|
|
201
|
+
tokens_used: int = 0,
|
|
202
|
+
cost: float = 0.0,
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
"""
|
|
205
|
+
Record final completion with overall results.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
success: Whether execution succeeded
|
|
209
|
+
response: Successful response/output
|
|
210
|
+
error: Error message if failed
|
|
211
|
+
tokens_used: Tokens consumed
|
|
212
|
+
cost: Execution cost
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Completion event dictionary
|
|
216
|
+
"""
|
|
217
|
+
total_duration = time.time() - self.start_time
|
|
218
|
+
|
|
219
|
+
completion_event: dict[str, Any] = {
|
|
220
|
+
"success": success,
|
|
221
|
+
"duration": total_duration,
|
|
222
|
+
"tokens": tokens_used,
|
|
223
|
+
"cost": cost,
|
|
224
|
+
"phase_count": len(self.phase_events),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if success:
|
|
228
|
+
completion_event["response"] = response
|
|
229
|
+
else:
|
|
230
|
+
completion_event["error"] = error
|
|
231
|
+
|
|
232
|
+
return completion_event
|
|
233
|
+
|
|
234
|
+
def get_phase_events(self) -> dict[str, dict[str, Any]]:
|
|
235
|
+
"""Get all recorded phase events."""
|
|
236
|
+
return self.phase_events
|
|
237
|
+
|
|
238
|
+
def record_tool_call(
|
|
239
|
+
self,
|
|
240
|
+
tool_name: str,
|
|
241
|
+
tool_input: dict | None,
|
|
242
|
+
phase_event_id: str,
|
|
243
|
+
spawned_agent: str | None = None,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""
|
|
246
|
+
Record a tool call within a spawned execution phase.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
tool_name: Name of the tool (bash, read_file, write_file, etc.)
|
|
250
|
+
tool_input: Input parameters to the tool
|
|
251
|
+
phase_event_id: Parent phase event ID to link to
|
|
252
|
+
spawned_agent: Agent making the tool call (optional)
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Event dictionary with event_id and metadata
|
|
256
|
+
"""
|
|
257
|
+
if not self.db:
|
|
258
|
+
return {}
|
|
259
|
+
|
|
260
|
+
event_id = f"event-{uuid.uuid4().hex[:8]}"
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
context = {
|
|
264
|
+
"tool_name": tool_name,
|
|
265
|
+
"spawner_type": self.spawner_type,
|
|
266
|
+
"parent_phase_event": phase_event_id,
|
|
267
|
+
}
|
|
268
|
+
if spawned_agent:
|
|
269
|
+
context["spawned_agent"] = spawned_agent
|
|
270
|
+
|
|
271
|
+
self.db.insert_event(
|
|
272
|
+
event_id=event_id,
|
|
273
|
+
agent_id=spawned_agent or self.parent_agent,
|
|
274
|
+
event_type="tool_call",
|
|
275
|
+
session_id=self.session_id,
|
|
276
|
+
tool_name=tool_name,
|
|
277
|
+
input_summary=(
|
|
278
|
+
str(tool_input)[:200] if tool_input else f"Call to {tool_name}"
|
|
279
|
+
),
|
|
280
|
+
context=context,
|
|
281
|
+
parent_event_id=phase_event_id,
|
|
282
|
+
subagent_type=self.spawner_type,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
tool_event = {
|
|
286
|
+
"event_id": event_id,
|
|
287
|
+
"tool_name": tool_name,
|
|
288
|
+
"tool_input": tool_input,
|
|
289
|
+
"phase_event_id": phase_event_id,
|
|
290
|
+
"spawned_agent": spawned_agent,
|
|
291
|
+
"status": "running",
|
|
292
|
+
"start_time": time.time(),
|
|
293
|
+
}
|
|
294
|
+
return tool_event
|
|
295
|
+
|
|
296
|
+
except Exception:
|
|
297
|
+
# Non-fatal - tracking is best-effort
|
|
298
|
+
return {}
|
|
299
|
+
|
|
300
|
+
def complete_tool_call(
|
|
301
|
+
self,
|
|
302
|
+
event_id: str,
|
|
303
|
+
output_summary: str | None = None,
|
|
304
|
+
success: bool = True,
|
|
305
|
+
) -> bool:
|
|
306
|
+
"""
|
|
307
|
+
Mark a tool call as complete with results.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
event_id: Event ID from record_tool_call
|
|
311
|
+
output_summary: Summary of tool output/result
|
|
312
|
+
success: Whether the tool call succeeded
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
True if update successful, False otherwise
|
|
316
|
+
"""
|
|
317
|
+
if not self.db or not event_id:
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
if self.db.connection is None:
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
cursor = self.db.connection.cursor()
|
|
325
|
+
cursor.execute(
|
|
326
|
+
"""
|
|
327
|
+
UPDATE agent_events
|
|
328
|
+
SET output_summary = ?, status = ?, updated_at = CURRENT_TIMESTAMP
|
|
329
|
+
WHERE event_id = ?
|
|
330
|
+
""",
|
|
331
|
+
(
|
|
332
|
+
output_summary,
|
|
333
|
+
"completed" if success else "failed",
|
|
334
|
+
event_id,
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
self.db.connection.commit()
|
|
338
|
+
return True
|
|
339
|
+
except Exception:
|
|
340
|
+
# Non-fatal
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
def get_event_hierarchy(self) -> dict[str, Any]:
|
|
344
|
+
"""
|
|
345
|
+
Get the event hierarchy for this spawner execution.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Dictionary with delegation event as root and phases as children
|
|
349
|
+
"""
|
|
350
|
+
return {
|
|
351
|
+
"delegation_event_id": self.delegation_event_id,
|
|
352
|
+
"spawner_type": self.spawner_type,
|
|
353
|
+
"session_id": self.session_id,
|
|
354
|
+
"total_duration": time.time() - self.start_time,
|
|
355
|
+
"phase_events": self.phase_events,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def create_tracker_from_env(
|
|
360
|
+
spawner_type: str = "generic",
|
|
361
|
+
) -> SpawnerEventTracker:
|
|
362
|
+
"""
|
|
363
|
+
Create a SpawnerEventTracker from environment variables.
|
|
364
|
+
|
|
365
|
+
Reads HTMLGRAPH_PARENT_EVENT, HTMLGRAPH_PARENT_AGENT, HTMLGRAPH_PARENT_SESSION
|
|
366
|
+
from environment to link to parent context.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
spawner_type: Type of spawner (gemini, codex, copilot)
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Initialized SpawnerEventTracker
|
|
373
|
+
"""
|
|
374
|
+
delegation_event_id = os.getenv("HTMLGRAPH_PARENT_EVENT")
|
|
375
|
+
parent_agent = os.getenv("HTMLGRAPH_PARENT_AGENT", "orchestrator")
|
|
376
|
+
session_id = os.getenv("HTMLGRAPH_PARENT_SESSION")
|
|
377
|
+
|
|
378
|
+
return SpawnerEventTracker(
|
|
379
|
+
delegation_event_id=delegation_event_id,
|
|
380
|
+
parent_agent=parent_agent,
|
|
381
|
+
spawner_type=spawner_type,
|
|
382
|
+
session_id=session_id,
|
|
383
|
+
)
|