htmlgraph 0.23.5__py3-none-any.whl → 0.24.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- htmlgraph/__init__.py +5 -1
- htmlgraph/cigs/__init__.py +77 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli.py +325 -11
- htmlgraph/hooks/cigs_pretool_enforcer.py +350 -0
- htmlgraph/hooks/posttooluse.py +50 -2
- htmlgraph/hooks/task_enforcer.py +60 -4
- htmlgraph/models.py +14 -1
- htmlgraph/orchestration/headless_spawner.py +519 -21
- htmlgraph/orchestrator-system-prompt-optimized.txt +259 -53
- htmlgraph/reflection.py +442 -0
- htmlgraph/sdk.py +26 -9
- {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.0.dist-info}/METADATA +2 -1
- {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.0.dist-info}/RECORD +29 -17
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.23.5.data → htmlgraph-0.24.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.0.dist-info}/WHEEL +0 -0
- {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"""Headless AI spawner for multi-AI orchestration."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import subprocess
|
|
5
6
|
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from htmlgraph.sdk import SDK
|
|
6
11
|
|
|
7
12
|
|
|
8
13
|
@dataclass
|
|
@@ -14,6 +19,7 @@ class AIResult:
|
|
|
14
19
|
tokens_used: int | None
|
|
15
20
|
error: str | None
|
|
16
21
|
raw_output: dict | list | str | None
|
|
22
|
+
tracked_events: list[dict] | None = None # Events tracked in HtmlGraph
|
|
17
23
|
|
|
18
24
|
|
|
19
25
|
class HeadlessSpawner:
|
|
@@ -59,12 +65,316 @@ class HeadlessSpawner:
|
|
|
59
65
|
"""Initialize spawner."""
|
|
60
66
|
pass
|
|
61
67
|
|
|
68
|
+
def _get_sdk(self) -> "SDK | None":
|
|
69
|
+
"""
|
|
70
|
+
Get SDK instance for HtmlGraph tracking with parent session support.
|
|
71
|
+
|
|
72
|
+
Returns None if SDK unavailable.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
from htmlgraph.sdk import SDK
|
|
76
|
+
|
|
77
|
+
# Read parent session context from environment
|
|
78
|
+
parent_session = os.getenv("HTMLGRAPH_PARENT_SESSION")
|
|
79
|
+
parent_agent = os.getenv("HTMLGRAPH_PARENT_AGENT")
|
|
80
|
+
|
|
81
|
+
# Create SDK with parent session context
|
|
82
|
+
sdk = SDK(
|
|
83
|
+
agent=f"spawner-{parent_agent}" if parent_agent else "spawner",
|
|
84
|
+
parent_session=parent_session, # Pass parent session
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return sdk
|
|
88
|
+
|
|
89
|
+
except Exception:
|
|
90
|
+
# SDK unavailable or not properly initialized (optional dependency)
|
|
91
|
+
# This happens in test contexts without active sessions
|
|
92
|
+
# Don't log error to avoid noise in tests
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _parse_and_track_gemini_events(
|
|
96
|
+
self, jsonl_output: str, sdk: "SDK"
|
|
97
|
+
) -> list[dict]:
|
|
98
|
+
"""
|
|
99
|
+
Parse Gemini stream-json events and track in HtmlGraph.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
jsonl_output: JSONL output from Gemini CLI
|
|
103
|
+
sdk: HtmlGraph SDK instance for tracking
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Parsed events list
|
|
107
|
+
"""
|
|
108
|
+
events = []
|
|
109
|
+
|
|
110
|
+
# Get parent context for metadata
|
|
111
|
+
parent_activity = os.getenv("HTMLGRAPH_PARENT_ACTIVITY")
|
|
112
|
+
nesting_depth_str = os.getenv("HTMLGRAPH_NESTING_DEPTH", "0")
|
|
113
|
+
nesting_depth = int(nesting_depth_str) if nesting_depth_str.isdigit() else 0
|
|
114
|
+
|
|
115
|
+
for line in jsonl_output.splitlines():
|
|
116
|
+
if not line.strip():
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
event = json.loads(line)
|
|
121
|
+
events.append(event)
|
|
122
|
+
|
|
123
|
+
# Track based on event type
|
|
124
|
+
event_type = event.get("type")
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
if event_type == "tool_use":
|
|
128
|
+
tool_name = event.get("tool_name", "unknown_tool")
|
|
129
|
+
parameters = event.get("parameters", {})
|
|
130
|
+
payload = {
|
|
131
|
+
"tool_name": tool_name,
|
|
132
|
+
"parameters": parameters,
|
|
133
|
+
}
|
|
134
|
+
if parent_activity:
|
|
135
|
+
payload["parent_activity"] = parent_activity
|
|
136
|
+
if nesting_depth > 0:
|
|
137
|
+
payload["nesting_depth"] = nesting_depth
|
|
138
|
+
sdk.track_activity(
|
|
139
|
+
tool="gemini_tool_call",
|
|
140
|
+
summary=f"Gemini called {tool_name}",
|
|
141
|
+
payload=payload,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
elif event_type == "tool_result":
|
|
145
|
+
status = event.get("status", "unknown")
|
|
146
|
+
success = status == "success"
|
|
147
|
+
tool_id = event.get("tool_id", "unknown")
|
|
148
|
+
payload = {"tool_id": tool_id, "status": status}
|
|
149
|
+
if parent_activity:
|
|
150
|
+
payload["parent_activity"] = parent_activity
|
|
151
|
+
if nesting_depth > 0:
|
|
152
|
+
payload["nesting_depth"] = nesting_depth
|
|
153
|
+
sdk.track_activity(
|
|
154
|
+
tool="gemini_tool_result",
|
|
155
|
+
summary=f"Gemini tool result: {status}",
|
|
156
|
+
success=success,
|
|
157
|
+
payload=payload,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
elif event_type == "message":
|
|
161
|
+
role = event.get("role")
|
|
162
|
+
if role == "assistant":
|
|
163
|
+
content = event.get("content", "")
|
|
164
|
+
# Truncate for summary
|
|
165
|
+
summary = (
|
|
166
|
+
content[:100] + "..." if len(content) > 100 else content
|
|
167
|
+
)
|
|
168
|
+
payload = {"role": role, "content_length": len(content)}
|
|
169
|
+
if parent_activity:
|
|
170
|
+
payload["parent_activity"] = parent_activity
|
|
171
|
+
if nesting_depth > 0:
|
|
172
|
+
payload["nesting_depth"] = nesting_depth
|
|
173
|
+
sdk.track_activity(
|
|
174
|
+
tool="gemini_message",
|
|
175
|
+
summary=f"Gemini: {summary}",
|
|
176
|
+
payload=payload,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
elif event_type == "result":
|
|
180
|
+
stats = event.get("stats", {})
|
|
181
|
+
payload = {"stats": stats}
|
|
182
|
+
if parent_activity:
|
|
183
|
+
payload["parent_activity"] = parent_activity
|
|
184
|
+
if nesting_depth > 0:
|
|
185
|
+
payload["nesting_depth"] = nesting_depth
|
|
186
|
+
sdk.track_activity(
|
|
187
|
+
tool="gemini_completion",
|
|
188
|
+
summary="Gemini task completed",
|
|
189
|
+
payload=payload,
|
|
190
|
+
)
|
|
191
|
+
except Exception:
|
|
192
|
+
# Tracking failure should not break parsing
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
except json.JSONDecodeError:
|
|
196
|
+
# Skip malformed lines
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
return events
|
|
200
|
+
|
|
201
|
+
def _parse_and_track_codex_events(
|
|
202
|
+
self, jsonl_output: str, sdk: "SDK"
|
|
203
|
+
) -> list[dict]:
|
|
204
|
+
"""
|
|
205
|
+
Parse Codex JSONL events and track in HtmlGraph.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
jsonl_output: JSONL output from Codex CLI
|
|
209
|
+
sdk: HtmlGraph SDK instance for tracking
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Parsed events list
|
|
213
|
+
"""
|
|
214
|
+
events = []
|
|
215
|
+
parse_errors = []
|
|
216
|
+
|
|
217
|
+
# Get parent context for metadata
|
|
218
|
+
parent_activity = os.getenv("HTMLGRAPH_PARENT_ACTIVITY")
|
|
219
|
+
nesting_depth_str = os.getenv("HTMLGRAPH_NESTING_DEPTH", "0")
|
|
220
|
+
nesting_depth = int(nesting_depth_str) if nesting_depth_str.isdigit() else 0
|
|
221
|
+
|
|
222
|
+
for line_num, line in enumerate(jsonl_output.splitlines(), start=1):
|
|
223
|
+
if not line.strip():
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
event = json.loads(line)
|
|
228
|
+
events.append(event)
|
|
229
|
+
|
|
230
|
+
event_type = event.get("type")
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
# Track item.started events
|
|
234
|
+
if event_type == "item.started":
|
|
235
|
+
item = event.get("item", {})
|
|
236
|
+
item_type = item.get("type")
|
|
237
|
+
|
|
238
|
+
if item_type == "command_execution":
|
|
239
|
+
command = item.get("command", "")
|
|
240
|
+
payload = {"command": command}
|
|
241
|
+
if parent_activity:
|
|
242
|
+
payload["parent_activity"] = parent_activity
|
|
243
|
+
if nesting_depth > 0:
|
|
244
|
+
payload["nesting_depth"] = nesting_depth
|
|
245
|
+
sdk.track_activity(
|
|
246
|
+
tool="codex_command",
|
|
247
|
+
summary=f"Codex executing: {command[:80]}",
|
|
248
|
+
payload=payload,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Track item.completed events
|
|
252
|
+
elif event_type == "item.completed":
|
|
253
|
+
item = event.get("item", {})
|
|
254
|
+
item_type = item.get("type")
|
|
255
|
+
|
|
256
|
+
if item_type == "file_change":
|
|
257
|
+
path = item.get("path", "unknown")
|
|
258
|
+
payload = {"path": path}
|
|
259
|
+
if parent_activity:
|
|
260
|
+
payload["parent_activity"] = parent_activity
|
|
261
|
+
if nesting_depth > 0:
|
|
262
|
+
payload["nesting_depth"] = nesting_depth
|
|
263
|
+
sdk.track_activity(
|
|
264
|
+
tool="codex_file_change",
|
|
265
|
+
summary=f"Codex modified: {path}",
|
|
266
|
+
file_paths=[path],
|
|
267
|
+
payload=payload,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
elif item_type == "agent_message":
|
|
271
|
+
text = item.get("text", "")
|
|
272
|
+
summary = text[:100] + "..." if len(text) > 100 else text
|
|
273
|
+
payload = {"text_length": len(text)}
|
|
274
|
+
if parent_activity:
|
|
275
|
+
payload["parent_activity"] = parent_activity
|
|
276
|
+
if nesting_depth > 0:
|
|
277
|
+
payload["nesting_depth"] = nesting_depth
|
|
278
|
+
sdk.track_activity(
|
|
279
|
+
tool="codex_message",
|
|
280
|
+
summary=f"Codex: {summary}",
|
|
281
|
+
payload=payload,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Track turn.completed for token usage
|
|
285
|
+
elif event_type == "turn.completed":
|
|
286
|
+
usage = event.get("usage", {})
|
|
287
|
+
total_tokens = sum(usage.values())
|
|
288
|
+
payload = {"usage": usage}
|
|
289
|
+
if parent_activity:
|
|
290
|
+
payload["parent_activity"] = parent_activity
|
|
291
|
+
if nesting_depth > 0:
|
|
292
|
+
payload["nesting_depth"] = nesting_depth
|
|
293
|
+
sdk.track_activity(
|
|
294
|
+
tool="codex_completion",
|
|
295
|
+
summary=f"Codex turn completed ({total_tokens} tokens)",
|
|
296
|
+
payload=payload,
|
|
297
|
+
)
|
|
298
|
+
except Exception:
|
|
299
|
+
# Tracking failure should not break parsing
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
except json.JSONDecodeError as e:
|
|
303
|
+
parse_errors.append(
|
|
304
|
+
{
|
|
305
|
+
"line_number": line_num,
|
|
306
|
+
"error": str(e),
|
|
307
|
+
"content": line[:100],
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
return events
|
|
313
|
+
|
|
314
|
+
def _parse_and_track_copilot_events(
|
|
315
|
+
self, prompt: str, response: str, sdk: "SDK"
|
|
316
|
+
) -> list[dict]:
|
|
317
|
+
"""
|
|
318
|
+
Track Copilot execution (start and result only).
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
prompt: Original prompt
|
|
322
|
+
response: Response from Copilot
|
|
323
|
+
sdk: HtmlGraph SDK instance for tracking
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Synthetic events list for consistency
|
|
327
|
+
"""
|
|
328
|
+
events = []
|
|
329
|
+
|
|
330
|
+
# Get parent context for metadata
|
|
331
|
+
parent_activity = os.getenv("HTMLGRAPH_PARENT_ACTIVITY")
|
|
332
|
+
nesting_depth_str = os.getenv("HTMLGRAPH_NESTING_DEPTH", "0")
|
|
333
|
+
nesting_depth = int(nesting_depth_str) if nesting_depth_str.isdigit() else 0
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
# Track start
|
|
337
|
+
start_event = {"type": "copilot_start", "prompt": prompt[:100]}
|
|
338
|
+
events.append(start_event)
|
|
339
|
+
payload: dict[str, str | int] = {"prompt_length": len(prompt)}
|
|
340
|
+
if parent_activity:
|
|
341
|
+
payload["parent_activity"] = parent_activity
|
|
342
|
+
if nesting_depth > 0:
|
|
343
|
+
payload["nesting_depth"] = nesting_depth
|
|
344
|
+
sdk.track_activity(
|
|
345
|
+
tool="copilot_start",
|
|
346
|
+
summary=f"Copilot started with prompt: {prompt[:80]}",
|
|
347
|
+
payload=payload,
|
|
348
|
+
)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
# Track result
|
|
354
|
+
result_event = {"type": "copilot_result", "response": response[:100]}
|
|
355
|
+
events.append(result_event)
|
|
356
|
+
payload_result: dict[str, str | int] = {"response_length": len(response)}
|
|
357
|
+
if parent_activity:
|
|
358
|
+
payload_result["parent_activity"] = parent_activity
|
|
359
|
+
if nesting_depth > 0:
|
|
360
|
+
payload_result["nesting_depth"] = nesting_depth
|
|
361
|
+
sdk.track_activity(
|
|
362
|
+
tool="copilot_result",
|
|
363
|
+
summary=f"Copilot completed: {response[:80]}",
|
|
364
|
+
payload=payload_result,
|
|
365
|
+
)
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
return events
|
|
370
|
+
|
|
62
371
|
def spawn_gemini(
|
|
63
372
|
self,
|
|
64
373
|
prompt: str,
|
|
65
|
-
output_format: str = "json",
|
|
374
|
+
output_format: str = "stream-json",
|
|
66
375
|
model: str | None = None,
|
|
67
376
|
include_directories: list[str] | None = None,
|
|
377
|
+
track_in_htmlgraph: bool = True,
|
|
68
378
|
timeout: int = 120,
|
|
69
379
|
) -> AIResult:
|
|
70
380
|
"""
|
|
@@ -72,14 +382,21 @@ class HeadlessSpawner:
|
|
|
72
382
|
|
|
73
383
|
Args:
|
|
74
384
|
prompt: Task description for Gemini
|
|
75
|
-
output_format: "json" or "stream-json"
|
|
385
|
+
output_format: "json" or "stream-json" (default: "stream-json" for real-time tracking)
|
|
76
386
|
model: Model selection (e.g., "gemini-2.0-flash"). Default: None (uses default)
|
|
77
387
|
include_directories: List of directories to include for context. Default: None
|
|
388
|
+
track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
|
|
78
389
|
timeout: Max seconds to wait
|
|
79
390
|
|
|
80
391
|
Returns:
|
|
81
|
-
AIResult with response or error
|
|
392
|
+
AIResult with response or error and tracked events if tracking enabled
|
|
82
393
|
"""
|
|
394
|
+
# Initialize tracking if enabled
|
|
395
|
+
sdk: SDK | None = None
|
|
396
|
+
tracked_events: list[dict] = []
|
|
397
|
+
if track_in_htmlgraph:
|
|
398
|
+
sdk = self._get_sdk()
|
|
399
|
+
|
|
83
400
|
try:
|
|
84
401
|
# Build command based on tested pattern from spike spk-4029eef3
|
|
85
402
|
cmd = ["gemini", "-p", prompt, "--output-format", output_format]
|
|
@@ -96,6 +413,18 @@ class HeadlessSpawner:
|
|
|
96
413
|
# CRITICAL: Add --yolo for headless mode (auto-approve all tools)
|
|
97
414
|
cmd.append("--yolo")
|
|
98
415
|
|
|
416
|
+
# Track spawner start if SDK available
|
|
417
|
+
if sdk:
|
|
418
|
+
try:
|
|
419
|
+
sdk.track_activity(
|
|
420
|
+
tool="gemini_spawn_start",
|
|
421
|
+
summary=f"Spawning Gemini: {prompt[:80]}",
|
|
422
|
+
payload={"prompt_length": len(prompt), "model": model},
|
|
423
|
+
)
|
|
424
|
+
except Exception:
|
|
425
|
+
# Tracking failure should not break execution
|
|
426
|
+
pass
|
|
427
|
+
|
|
99
428
|
# Execute with timeout and stderr redirection
|
|
100
429
|
# Note: Cannot use capture_output with stderr parameter
|
|
101
430
|
result = subprocess.run(
|
|
@@ -114,9 +443,58 @@ class HeadlessSpawner:
|
|
|
114
443
|
tokens_used=None,
|
|
115
444
|
error=f"Gemini CLI failed with exit code {result.returncode}",
|
|
116
445
|
raw_output=None,
|
|
446
|
+
tracked_events=tracked_events,
|
|
117
447
|
)
|
|
118
448
|
|
|
119
|
-
#
|
|
449
|
+
# Handle stream-json format with real-time tracking
|
|
450
|
+
if output_format == "stream-json" and sdk:
|
|
451
|
+
try:
|
|
452
|
+
tracked_events = self._parse_and_track_gemini_events(
|
|
453
|
+
result.stdout, sdk
|
|
454
|
+
)
|
|
455
|
+
# Only use stream-json parsing if we got valid events
|
|
456
|
+
if tracked_events:
|
|
457
|
+
# For stream-json, we need to extract response differently
|
|
458
|
+
# Look for the last message or result event
|
|
459
|
+
response_text = ""
|
|
460
|
+
for event in tracked_events:
|
|
461
|
+
if event.get("type") == "result":
|
|
462
|
+
response_text = event.get("response", "")
|
|
463
|
+
break
|
|
464
|
+
elif event.get("type") == "message":
|
|
465
|
+
content = event.get("content", "")
|
|
466
|
+
if content:
|
|
467
|
+
response_text = content
|
|
468
|
+
|
|
469
|
+
# Token usage from stats in result event
|
|
470
|
+
tokens = None
|
|
471
|
+
for event in tracked_events:
|
|
472
|
+
if event.get("type") == "result":
|
|
473
|
+
stats = event.get("stats", {})
|
|
474
|
+
if stats and "models" in stats:
|
|
475
|
+
total_tokens = 0
|
|
476
|
+
for model_stats in stats["models"].values():
|
|
477
|
+
model_tokens = model_stats.get(
|
|
478
|
+
"tokens", {}
|
|
479
|
+
).get("total", 0)
|
|
480
|
+
total_tokens += model_tokens
|
|
481
|
+
tokens = total_tokens if total_tokens > 0 else None
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
return AIResult(
|
|
485
|
+
success=True,
|
|
486
|
+
response=response_text,
|
|
487
|
+
tokens_used=tokens,
|
|
488
|
+
error=None,
|
|
489
|
+
raw_output={"events": tracked_events},
|
|
490
|
+
tracked_events=tracked_events,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
except Exception:
|
|
494
|
+
# Fall back to regular JSON parsing if tracking fails
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
# Parse JSON response (for json format or fallback)
|
|
120
498
|
try:
|
|
121
499
|
output = json.loads(result.stdout)
|
|
122
500
|
except json.JSONDecodeError as e:
|
|
@@ -126,6 +504,7 @@ class HeadlessSpawner:
|
|
|
126
504
|
tokens_used=None,
|
|
127
505
|
error=f"Failed to parse JSON output: {e}",
|
|
128
506
|
raw_output={"stdout": result.stdout},
|
|
507
|
+
tracked_events=tracked_events,
|
|
129
508
|
)
|
|
130
509
|
|
|
131
510
|
# Extract response and token usage from parsed output
|
|
@@ -148,15 +527,22 @@ class HeadlessSpawner:
|
|
|
148
527
|
tokens_used=tokens,
|
|
149
528
|
error=None,
|
|
150
529
|
raw_output=output,
|
|
530
|
+
tracked_events=tracked_events,
|
|
151
531
|
)
|
|
152
532
|
|
|
153
|
-
except subprocess.TimeoutExpired:
|
|
533
|
+
except subprocess.TimeoutExpired as e:
|
|
154
534
|
return AIResult(
|
|
155
535
|
success=False,
|
|
156
536
|
response="",
|
|
157
537
|
tokens_used=None,
|
|
158
538
|
error=f"Gemini CLI timed out after {timeout} seconds",
|
|
159
|
-
raw_output=
|
|
539
|
+
raw_output={
|
|
540
|
+
"partial_stdout": e.stdout.decode() if e.stdout else None,
|
|
541
|
+
"partial_stderr": e.stderr.decode() if e.stderr else None,
|
|
542
|
+
}
|
|
543
|
+
if e.stdout or e.stderr
|
|
544
|
+
else None,
|
|
545
|
+
tracked_events=tracked_events,
|
|
160
546
|
)
|
|
161
547
|
except FileNotFoundError:
|
|
162
548
|
return AIResult(
|
|
@@ -165,6 +551,7 @@ class HeadlessSpawner:
|
|
|
165
551
|
tokens_used=None,
|
|
166
552
|
error="Gemini CLI not found. Ensure 'gemini' is installed and in PATH.",
|
|
167
553
|
raw_output=None,
|
|
554
|
+
tracked_events=tracked_events,
|
|
168
555
|
)
|
|
169
556
|
except Exception as e:
|
|
170
557
|
return AIResult(
|
|
@@ -173,6 +560,7 @@ class HeadlessSpawner:
|
|
|
173
560
|
tokens_used=None,
|
|
174
561
|
error=f"Unexpected error: {type(e).__name__}: {e}",
|
|
175
562
|
raw_output=None,
|
|
563
|
+
tracked_events=tracked_events,
|
|
176
564
|
)
|
|
177
565
|
|
|
178
566
|
def spawn_codex(
|
|
@@ -189,6 +577,7 @@ class HeadlessSpawner:
|
|
|
189
577
|
working_directory: str | None = None,
|
|
190
578
|
use_oss: bool = False,
|
|
191
579
|
bypass_approvals: bool = False,
|
|
580
|
+
track_in_htmlgraph: bool = True,
|
|
192
581
|
timeout: int = 120,
|
|
193
582
|
) -> AIResult:
|
|
194
583
|
"""
|
|
@@ -196,7 +585,7 @@ class HeadlessSpawner:
|
|
|
196
585
|
|
|
197
586
|
Args:
|
|
198
587
|
prompt: Task description for Codex
|
|
199
|
-
output_json: Use --json flag for JSONL output
|
|
588
|
+
output_json: Use --json flag for JSONL output (enables real-time tracking)
|
|
200
589
|
model: Model selection (e.g., "gpt-4-turbo"). Default: None
|
|
201
590
|
sandbox: Sandbox mode ("read-only", "workspace-write", "danger-full-access"). Default: None
|
|
202
591
|
full_auto: Enable full auto mode (--full-auto). Default: True (required for headless)
|
|
@@ -207,11 +596,18 @@ class HeadlessSpawner:
|
|
|
207
596
|
working_directory: Workspace directory (--cd). Default: None
|
|
208
597
|
use_oss: Use local Ollama provider (--oss). Default: False
|
|
209
598
|
bypass_approvals: Dangerously bypass approvals (--dangerously-bypass-approvals-and-sandbox). Default: False
|
|
599
|
+
track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
|
|
210
600
|
timeout: Max seconds to wait
|
|
211
601
|
|
|
212
602
|
Returns:
|
|
213
|
-
AIResult with response
|
|
603
|
+
AIResult with response, error, and tracked events if tracking enabled
|
|
214
604
|
"""
|
|
605
|
+
# Initialize tracking if enabled
|
|
606
|
+
sdk: SDK | None = None
|
|
607
|
+
tracked_events: list[dict] = []
|
|
608
|
+
if track_in_htmlgraph and output_json:
|
|
609
|
+
sdk = self._get_sdk()
|
|
610
|
+
|
|
215
611
|
cmd = ["codex", "exec"]
|
|
216
612
|
|
|
217
613
|
if output_json:
|
|
@@ -261,6 +657,22 @@ class HeadlessSpawner:
|
|
|
261
657
|
# Add prompt as final argument
|
|
262
658
|
cmd.append(prompt)
|
|
263
659
|
|
|
660
|
+
# Track spawner start if SDK available
|
|
661
|
+
if sdk:
|
|
662
|
+
try:
|
|
663
|
+
sdk.track_activity(
|
|
664
|
+
tool="codex_spawn_start",
|
|
665
|
+
summary=f"Spawning Codex: {prompt[:80]}",
|
|
666
|
+
payload={
|
|
667
|
+
"prompt_length": len(prompt),
|
|
668
|
+
"model": model,
|
|
669
|
+
"sandbox": sandbox,
|
|
670
|
+
},
|
|
671
|
+
)
|
|
672
|
+
except Exception:
|
|
673
|
+
# Tracking failure should not break execution
|
|
674
|
+
pass
|
|
675
|
+
|
|
264
676
|
try:
|
|
265
677
|
result = subprocess.run(
|
|
266
678
|
cmd,
|
|
@@ -278,16 +690,34 @@ class HeadlessSpawner:
|
|
|
278
690
|
tokens_used=None,
|
|
279
691
|
error=None if result.returncode == 0 else "Command failed",
|
|
280
692
|
raw_output=result.stdout,
|
|
693
|
+
tracked_events=tracked_events,
|
|
281
694
|
)
|
|
282
695
|
|
|
283
696
|
# Parse JSONL output
|
|
284
697
|
events = []
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
698
|
+
parse_errors = []
|
|
699
|
+
|
|
700
|
+
# Use tracking parser if SDK is available
|
|
701
|
+
if sdk:
|
|
702
|
+
tracked_events = self._parse_and_track_codex_events(result.stdout, sdk)
|
|
703
|
+
events = tracked_events
|
|
704
|
+
else:
|
|
705
|
+
# Fallback to regular parsing without tracking
|
|
706
|
+
for line_num, line in enumerate(result.stdout.splitlines(), start=1):
|
|
707
|
+
if line.strip():
|
|
708
|
+
try:
|
|
709
|
+
events.append(json.loads(line))
|
|
710
|
+
except json.JSONDecodeError as e:
|
|
711
|
+
parse_errors.append(
|
|
712
|
+
{
|
|
713
|
+
"line_number": line_num,
|
|
714
|
+
"error": str(e),
|
|
715
|
+
"content": line[
|
|
716
|
+
:100
|
|
717
|
+
], # First 100 chars for debugging
|
|
718
|
+
}
|
|
719
|
+
)
|
|
720
|
+
continue
|
|
291
721
|
|
|
292
722
|
# Extract agent message
|
|
293
723
|
response = None
|
|
@@ -310,7 +740,11 @@ class HeadlessSpawner:
|
|
|
310
740
|
response=response or "",
|
|
311
741
|
tokens_used=tokens,
|
|
312
742
|
error=None if result.returncode == 0 else "Command failed",
|
|
313
|
-
raw_output=
|
|
743
|
+
raw_output={
|
|
744
|
+
"events": events,
|
|
745
|
+
"parse_errors": parse_errors if parse_errors else None,
|
|
746
|
+
},
|
|
747
|
+
tracked_events=tracked_events,
|
|
314
748
|
)
|
|
315
749
|
|
|
316
750
|
except FileNotFoundError:
|
|
@@ -320,14 +754,30 @@ class HeadlessSpawner:
|
|
|
320
754
|
tokens_used=None,
|
|
321
755
|
error="Codex CLI not found. Install from: https://github.com/openai/codex",
|
|
322
756
|
raw_output=None,
|
|
757
|
+
tracked_events=tracked_events,
|
|
323
758
|
)
|
|
324
|
-
except subprocess.TimeoutExpired:
|
|
759
|
+
except subprocess.TimeoutExpired as e:
|
|
325
760
|
return AIResult(
|
|
326
761
|
success=False,
|
|
327
762
|
response="",
|
|
328
763
|
tokens_used=None,
|
|
329
764
|
error=f"Timed out after {timeout} seconds",
|
|
765
|
+
raw_output={
|
|
766
|
+
"partial_stdout": e.stdout.decode() if e.stdout else None,
|
|
767
|
+
"partial_stderr": e.stderr.decode() if e.stderr else None,
|
|
768
|
+
}
|
|
769
|
+
if e.stdout or e.stderr
|
|
770
|
+
else None,
|
|
771
|
+
tracked_events=tracked_events,
|
|
772
|
+
)
|
|
773
|
+
except Exception as e:
|
|
774
|
+
return AIResult(
|
|
775
|
+
success=False,
|
|
776
|
+
response="",
|
|
777
|
+
tokens_used=None,
|
|
778
|
+
error=f"Unexpected error: {type(e).__name__}: {e}",
|
|
330
779
|
raw_output=None,
|
|
780
|
+
tracked_events=tracked_events,
|
|
331
781
|
)
|
|
332
782
|
|
|
333
783
|
def spawn_copilot(
|
|
@@ -336,6 +786,7 @@ class HeadlessSpawner:
|
|
|
336
786
|
allow_tools: list[str] | None = None,
|
|
337
787
|
allow_all_tools: bool = False,
|
|
338
788
|
deny_tools: list[str] | None = None,
|
|
789
|
+
track_in_htmlgraph: bool = True,
|
|
339
790
|
timeout: int = 120,
|
|
340
791
|
) -> AIResult:
|
|
341
792
|
"""
|
|
@@ -346,11 +797,18 @@ class HeadlessSpawner:
|
|
|
346
797
|
allow_tools: List of tools to auto-approve (e.g., ["shell(git)", "write(*.py)"])
|
|
347
798
|
allow_all_tools: Auto-approve all tools (--allow-all-tools). Default: False
|
|
348
799
|
deny_tools: List of tools to deny (--deny-tool). Default: None
|
|
800
|
+
track_in_htmlgraph: Enable HtmlGraph activity tracking. Default: True
|
|
349
801
|
timeout: Max seconds to wait
|
|
350
802
|
|
|
351
803
|
Returns:
|
|
352
|
-
AIResult with response
|
|
804
|
+
AIResult with response, error, and tracked events if tracking enabled
|
|
353
805
|
"""
|
|
806
|
+
# Initialize tracking if enabled
|
|
807
|
+
sdk = None
|
|
808
|
+
tracked_events = []
|
|
809
|
+
if track_in_htmlgraph:
|
|
810
|
+
sdk = self._get_sdk()
|
|
811
|
+
|
|
354
812
|
cmd = ["copilot", "-p", prompt]
|
|
355
813
|
|
|
356
814
|
# Add allow all tools flag
|
|
@@ -367,6 +825,18 @@ class HeadlessSpawner:
|
|
|
367
825
|
for tool in deny_tools:
|
|
368
826
|
cmd.extend(["--deny-tool", tool])
|
|
369
827
|
|
|
828
|
+
# Track spawner start if SDK available
|
|
829
|
+
if sdk:
|
|
830
|
+
try:
|
|
831
|
+
sdk.track_activity(
|
|
832
|
+
tool="copilot_spawn_start",
|
|
833
|
+
summary=f"Spawning Copilot: {prompt[:80]}",
|
|
834
|
+
payload={"prompt_length": len(prompt)},
|
|
835
|
+
)
|
|
836
|
+
except Exception:
|
|
837
|
+
# Tracking failure should not break execution
|
|
838
|
+
pass
|
|
839
|
+
|
|
370
840
|
try:
|
|
371
841
|
result = subprocess.run(
|
|
372
842
|
cmd,
|
|
@@ -398,12 +868,19 @@ class HeadlessSpawner:
|
|
|
398
868
|
tokens = 0 # Placeholder
|
|
399
869
|
break
|
|
400
870
|
|
|
871
|
+
# Track Copilot execution if SDK available
|
|
872
|
+
if sdk:
|
|
873
|
+
tracked_events = self._parse_and_track_copilot_events(
|
|
874
|
+
prompt, response, sdk
|
|
875
|
+
)
|
|
876
|
+
|
|
401
877
|
return AIResult(
|
|
402
878
|
success=result.returncode == 0,
|
|
403
879
|
response=response,
|
|
404
880
|
tokens_used=tokens,
|
|
405
881
|
error=None if result.returncode == 0 else result.stderr,
|
|
406
882
|
raw_output=result.stdout,
|
|
883
|
+
tracked_events=tracked_events,
|
|
407
884
|
)
|
|
408
885
|
|
|
409
886
|
except FileNotFoundError:
|
|
@@ -413,14 +890,30 @@ class HeadlessSpawner:
|
|
|
413
890
|
tokens_used=None,
|
|
414
891
|
error="Copilot CLI not found. Install from: https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line",
|
|
415
892
|
raw_output=None,
|
|
893
|
+
tracked_events=tracked_events,
|
|
416
894
|
)
|
|
417
|
-
except subprocess.TimeoutExpired:
|
|
895
|
+
except subprocess.TimeoutExpired as e:
|
|
418
896
|
return AIResult(
|
|
419
897
|
success=False,
|
|
420
898
|
response="",
|
|
421
899
|
tokens_used=None,
|
|
422
900
|
error=f"Timed out after {timeout} seconds",
|
|
901
|
+
raw_output={
|
|
902
|
+
"partial_stdout": e.stdout.decode() if e.stdout else None,
|
|
903
|
+
"partial_stderr": e.stderr.decode() if e.stderr else None,
|
|
904
|
+
}
|
|
905
|
+
if e.stdout or e.stderr
|
|
906
|
+
else None,
|
|
907
|
+
tracked_events=tracked_events,
|
|
908
|
+
)
|
|
909
|
+
except Exception as e:
|
|
910
|
+
return AIResult(
|
|
911
|
+
success=False,
|
|
912
|
+
response="",
|
|
913
|
+
tokens_used=None,
|
|
914
|
+
error=f"Unexpected error: {type(e).__name__}: {e}",
|
|
423
915
|
raw_output=None,
|
|
916
|
+
tracked_events=tracked_events,
|
|
424
917
|
)
|
|
425
918
|
|
|
426
919
|
def spawn_claude(
|
|
@@ -540,19 +1033,24 @@ class HeadlessSpawner:
|
|
|
540
1033
|
error="Claude CLI not found. Install Claude Code from: https://claude.com/claude-code",
|
|
541
1034
|
raw_output=None,
|
|
542
1035
|
)
|
|
543
|
-
except subprocess.TimeoutExpired:
|
|
1036
|
+
except subprocess.TimeoutExpired as e:
|
|
544
1037
|
return AIResult(
|
|
545
1038
|
success=False,
|
|
546
1039
|
response="",
|
|
547
1040
|
tokens_used=None,
|
|
548
1041
|
error=f"Timed out after {timeout} seconds",
|
|
549
|
-
raw_output=
|
|
1042
|
+
raw_output={
|
|
1043
|
+
"partial_stdout": e.stdout.decode() if e.stdout else None,
|
|
1044
|
+
"partial_stderr": e.stderr.decode() if e.stderr else None,
|
|
1045
|
+
}
|
|
1046
|
+
if e.stdout or e.stderr
|
|
1047
|
+
else None,
|
|
550
1048
|
)
|
|
551
1049
|
except Exception as e:
|
|
552
1050
|
return AIResult(
|
|
553
1051
|
success=False,
|
|
554
1052
|
response="",
|
|
555
1053
|
tokens_used=None,
|
|
556
|
-
error=f"Unexpected error: {
|
|
1054
|
+
error=f"Unexpected error: {type(e).__name__}: {e}",
|
|
557
1055
|
raw_output=None,
|
|
558
1056
|
)
|