@jaguilar87/gaia-ops 3.9.8 → 3.9.9
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.
package/hooks/subagent_stop.py
CHANGED
|
@@ -33,10 +33,22 @@ from pathlib import Path
|
|
|
33
33
|
from typing import Dict, List, Any, Optional
|
|
34
34
|
import hashlib
|
|
35
35
|
|
|
36
|
-
# Configure structured logging
|
|
36
|
+
# Configure structured logging with file handler (matching pre_tool_use.py pattern)
|
|
37
|
+
try:
|
|
38
|
+
from modules.core.paths import get_logs_dir
|
|
39
|
+
_log_dir = get_logs_dir()
|
|
40
|
+
except ImportError:
|
|
41
|
+
_log_dir = Path.cwd() / ".claude" / "logs"
|
|
42
|
+
_log_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
_log_file = _log_dir / f"subagent_stop-{os.getenv('USER', 'unknown')}.log"
|
|
37
45
|
logging.basicConfig(
|
|
38
46
|
level=logging.INFO,
|
|
39
|
-
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
47
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
48
|
+
handlers=[
|
|
49
|
+
logging.FileHandler(_log_file),
|
|
50
|
+
logging.StreamHandler()
|
|
51
|
+
]
|
|
40
52
|
)
|
|
41
53
|
logger = logging.getLogger(__name__)
|
|
42
54
|
|
|
@@ -668,41 +680,52 @@ def subagent_stop_hook(task_info: Dict[str, Any], agent_output: str) -> Dict[str
|
|
|
668
680
|
def _read_transcript(transcript_path: str) -> str:
|
|
669
681
|
"""Read agent transcript from file path provided by Claude Code.
|
|
670
682
|
|
|
671
|
-
Claude Code provides ``agent_transcript_path`` pointing to a JSONL file
|
|
672
|
-
|
|
673
|
-
|
|
683
|
+
Claude Code provides ``agent_transcript_path`` pointing to a JSONL file.
|
|
684
|
+
Each line has the structure:
|
|
685
|
+
{"type": "assistant", "message": {"role": "assistant", "content": [...]}, ...}
|
|
686
|
+
The role/content are nested inside the ``message`` field.
|
|
674
687
|
|
|
675
688
|
Falls back to empty string on any error so the hook never crashes.
|
|
676
689
|
"""
|
|
677
690
|
try:
|
|
678
|
-
|
|
691
|
+
# Expand ~ to home directory (Claude Code may use ~ in paths)
|
|
692
|
+
path = Path(transcript_path).expanduser()
|
|
693
|
+
logger.debug("Reading transcript from: %s", path)
|
|
694
|
+
|
|
679
695
|
if not path.exists():
|
|
680
|
-
logger.
|
|
696
|
+
logger.warning("Transcript file not found: %s", path)
|
|
681
697
|
return ""
|
|
682
698
|
|
|
683
699
|
lines = path.read_text().strip().splitlines()
|
|
700
|
+
|
|
684
701
|
text_parts: List[str] = []
|
|
685
702
|
for line in lines:
|
|
686
703
|
if not line.strip():
|
|
687
704
|
continue
|
|
688
705
|
try:
|
|
689
|
-
|
|
690
|
-
|
|
706
|
+
entry = json.loads(line)
|
|
707
|
+
|
|
708
|
+
# Claude Code transcript format: content is inside entry["message"]
|
|
709
|
+
msg = entry.get("message", entry) # fallback to entry itself for simple format
|
|
691
710
|
role = msg.get("role", "")
|
|
692
|
-
if role
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
711
|
+
if role != "assistant":
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
content = msg.get("content", "")
|
|
715
|
+
if isinstance(content, str):
|
|
716
|
+
text_parts.append(content)
|
|
717
|
+
elif isinstance(content, list):
|
|
718
|
+
for block in content:
|
|
719
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
720
|
+
text_parts.append(block.get("text", ""))
|
|
721
|
+
elif isinstance(block, str):
|
|
722
|
+
text_parts.append(block)
|
|
702
723
|
except (json.JSONDecodeError, TypeError):
|
|
703
724
|
continue
|
|
704
725
|
|
|
705
|
-
|
|
726
|
+
result = "\n".join(text_parts)
|
|
727
|
+
logger.debug("Extracted %d text parts, total length: %d chars", len(text_parts), len(result))
|
|
728
|
+
return result
|
|
706
729
|
|
|
707
730
|
except Exception as e:
|
|
708
731
|
logger.debug("Failed to read transcript from %s: %s", transcript_path, e)
|
|
@@ -803,7 +826,7 @@ if __name__ == "__main__":
|
|
|
803
826
|
|
|
804
827
|
hook_data = json.loads(stdin_data)
|
|
805
828
|
|
|
806
|
-
logger.info(f"Hook event: {hook_data.get('hook_event_name')}")
|
|
829
|
+
logger.info(f"Hook event: {hook_data.get('hook_event_name')}, agent: {hook_data.get('agent_type', 'unknown')}")
|
|
807
830
|
|
|
808
831
|
# Build task_info from Claude Code SubagentStop payload
|
|
809
832
|
task_info = _build_task_info_from_hook_data(hook_data)
|
|
@@ -811,6 +834,7 @@ if __name__ == "__main__":
|
|
|
811
834
|
# Extract agent output from transcript file
|
|
812
835
|
transcript_path = hook_data.get("agent_transcript_path", "")
|
|
813
836
|
agent_output = _read_transcript(transcript_path) if transcript_path else ""
|
|
837
|
+
logger.info(f"Agent output: {len(agent_output)} chars from transcript")
|
|
814
838
|
|
|
815
839
|
# Run the full processing chain
|
|
816
840
|
result = subagent_stop_hook(task_info, agent_output)
|
package/package.json
CHANGED
|
@@ -339,12 +339,15 @@ class TestStdinHandler:
|
|
|
339
339
|
"""Simulate Claude Code SubagentStop: pipe JSON via stdin with transcript."""
|
|
340
340
|
context_file = project_env["context_file"]
|
|
341
341
|
|
|
342
|
-
# Create a fake transcript JSONL file
|
|
342
|
+
# Create a fake transcript JSONL file (Claude Code format: content inside "message")
|
|
343
343
|
transcript_path = tmp_path / "agent_transcript.jsonl"
|
|
344
344
|
transcript_lines = [
|
|
345
345
|
json.dumps({
|
|
346
|
-
"
|
|
347
|
-
"
|
|
346
|
+
"type": "assistant",
|
|
347
|
+
"message": {
|
|
348
|
+
"role": "assistant",
|
|
349
|
+
"content": [{"type": "text", "text": AGENT_OUTPUT_WITH_CONTEXT_UPDATE}],
|
|
350
|
+
},
|
|
348
351
|
}),
|
|
349
352
|
]
|
|
350
353
|
transcript_path.write_text("\n".join(transcript_lines))
|
|
@@ -463,23 +466,26 @@ class TestStdinHandler:
|
|
|
463
466
|
"""Verify handling of transcript with content as list of blocks."""
|
|
464
467
|
context_file = project_env["context_file"]
|
|
465
468
|
|
|
466
|
-
# Create transcript with content as list (Claude
|
|
469
|
+
# Create transcript with content as list (Claude Code transcript format)
|
|
467
470
|
transcript_path = tmp_path / "agent_transcript_blocks.jsonl"
|
|
468
471
|
transcript_lines = [
|
|
469
472
|
json.dumps({
|
|
470
|
-
"
|
|
471
|
-
"
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
"
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
"
|
|
473
|
+
"type": "assistant",
|
|
474
|
+
"message": {
|
|
475
|
+
"role": "assistant",
|
|
476
|
+
"content": [
|
|
477
|
+
{"type": "text", "text": "## Namespace Validation Report\n\n20 namespaces found.\n\n"},
|
|
478
|
+
{"type": "text", "text": "CONTEXT_UPDATE:\n"},
|
|
479
|
+
{"type": "text", "text": json.dumps({
|
|
480
|
+
"cluster_details": {
|
|
481
|
+
"namespaces": {
|
|
482
|
+
"application": ["adm", "dev"],
|
|
483
|
+
"system": ["kube-system"],
|
|
484
|
+
}
|
|
479
485
|
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
|
|
486
|
+
})},
|
|
487
|
+
],
|
|
488
|
+
},
|
|
483
489
|
}),
|
|
484
490
|
]
|
|
485
491
|
transcript_path.write_text("\n".join(transcript_lines))
|
|
@@ -528,25 +534,33 @@ class TestReadTranscript:
|
|
|
528
534
|
"""Unit tests for the _read_transcript helper."""
|
|
529
535
|
|
|
530
536
|
def test_read_string_content(self, tmp_path):
|
|
537
|
+
"""Claude Code transcript format with string content inside message."""
|
|
531
538
|
mod = _import_subagent_stop()
|
|
532
539
|
transcript = tmp_path / "transcript.jsonl"
|
|
533
540
|
transcript.write_text(json.dumps({
|
|
534
|
-
"
|
|
535
|
-
"
|
|
541
|
+
"type": "assistant",
|
|
542
|
+
"message": {
|
|
543
|
+
"role": "assistant",
|
|
544
|
+
"content": "Hello world",
|
|
545
|
+
},
|
|
536
546
|
}))
|
|
537
547
|
|
|
538
548
|
result = mod._read_transcript(str(transcript))
|
|
539
549
|
assert "Hello world" in result
|
|
540
550
|
|
|
541
551
|
def test_read_list_content(self, tmp_path):
|
|
552
|
+
"""Claude Code transcript format with list content blocks."""
|
|
542
553
|
mod = _import_subagent_stop()
|
|
543
554
|
transcript = tmp_path / "transcript.jsonl"
|
|
544
555
|
transcript.write_text(json.dumps({
|
|
545
|
-
"
|
|
546
|
-
"
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
556
|
+
"type": "assistant",
|
|
557
|
+
"message": {
|
|
558
|
+
"role": "assistant",
|
|
559
|
+
"content": [
|
|
560
|
+
{"type": "text", "text": "Part 1"},
|
|
561
|
+
{"type": "text", "text": "Part 2"},
|
|
562
|
+
],
|
|
563
|
+
},
|
|
550
564
|
}))
|
|
551
565
|
|
|
552
566
|
result = mod._read_transcript(str(transcript))
|
|
@@ -554,11 +568,13 @@ class TestReadTranscript:
|
|
|
554
568
|
assert "Part 2" in result
|
|
555
569
|
|
|
556
570
|
def test_skips_user_messages(self, tmp_path):
|
|
571
|
+
"""Only assistant messages are extracted, user/progress entries are skipped."""
|
|
557
572
|
mod = _import_subagent_stop()
|
|
558
573
|
transcript = tmp_path / "transcript.jsonl"
|
|
559
574
|
lines = [
|
|
560
|
-
json.dumps({"role": "user", "content": "user message"}),
|
|
561
|
-
json.dumps({"role": "assistant", "content": "assistant message"}),
|
|
575
|
+
json.dumps({"type": "user", "message": {"role": "user", "content": "user message"}}),
|
|
576
|
+
json.dumps({"type": "assistant", "message": {"role": "assistant", "content": "assistant message"}}),
|
|
577
|
+
json.dumps({"type": "progress", "message": {}}),
|
|
562
578
|
]
|
|
563
579
|
transcript.write_text("\n".join(lines))
|
|
564
580
|
|
|
@@ -566,6 +582,18 @@ class TestReadTranscript:
|
|
|
566
582
|
assert "user message" not in result
|
|
567
583
|
assert "assistant message" in result
|
|
568
584
|
|
|
585
|
+
def test_fallback_simple_format(self, tmp_path):
|
|
586
|
+
"""Fallback: if no 'message' key, treat entry itself as the message."""
|
|
587
|
+
mod = _import_subagent_stop()
|
|
588
|
+
transcript = tmp_path / "transcript.jsonl"
|
|
589
|
+
transcript.write_text(json.dumps({
|
|
590
|
+
"role": "assistant",
|
|
591
|
+
"content": "simple format",
|
|
592
|
+
}))
|
|
593
|
+
|
|
594
|
+
result = mod._read_transcript(str(transcript))
|
|
595
|
+
assert "simple format" in result
|
|
596
|
+
|
|
569
597
|
def test_missing_file_returns_empty(self, tmp_path):
|
|
570
598
|
mod = _import_subagent_stop()
|
|
571
599
|
result = mod._read_transcript(str(tmp_path / "nonexistent.jsonl"))
|