File size: 10,045 Bytes
116524e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
#!/usr/bin/env python3
"""

Agentic System Prompting — Offline Trace Analysis



Feeds pre-recorded agent traces through TraceAnalyser (offline mode) to

extract reusable strategies into a skillbook.



Each trace file is loaded as a traces-format dict and passed directly

to RRStep via a thin adapter step, so the sandbox

receives the full conversation data.



TraceAnalyser handles the rest of the learning-tail pipeline:

    [RRTraceStep] → UpdateStep  (the agentic SkillManager mutates directly)



Usage:

    python recursive_agentic_system_prompting.py /path/to/traces

    python recursive_agentic_system_prompting.py /path/to/traces --model gpt-4o

    python recursive_agentic_system_prompting.py /path/to/traces --input-skillbook existing.json

    python recursive_agentic_system_prompting.py /path/to/traces --epochs 2



Options:

    traces_dir              Path to directory containing .json, .md, or .toon trace files

    --model, -m             LLM model for analysis (default: bedrock/us.anthropic.claude-sonnet-4-6)

    --threshold, -t         Deduplication similarity threshold 0.0-1.0 (default: 0.7)

    --epochs, -e            Number of passes over all traces (default: 1)

    --input-skillbook, -i   Path to existing skillbook to continue from

    --output-dir, -o        Output directory for results (default: script directory)

"""

import argparse
import json
import logging
import os
from datetime import datetime
from itertools import groupby
from pathlib import Path
from typing import Any, Dict, List

from dotenv import load_dotenv, find_dotenv

load_dotenv(find_dotenv())

# Show RR iteration progress
_handler = logging.StreamHandler()
_handler.setFormatter(
    logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
)
_logger = logging.getLogger("ace.steps.rr")
_logger.setLevel(logging.DEBUG)
_logger.addHandler(_handler)

from pipeline import Pipeline

from ace import TraceAnalyser, SkillManager, Skillbook
from ace.steps.rr_step import RRStep, RRConfig
from ace.core.context import ACEStepContext
from ace.deduplication import DeduplicationManager
from ace.protocols.deduplication import DeduplicationConfig
from ace.implementations.prompts import wrap_skillbook_for_external_agent
from ace.steps import UpdateStep, DeduplicateStep
from ace.implementations.rr.prompts import REFLECTOR_RECURSIVE_PROMPT


# ---------------------------------------------------------------------------
# Adapter step: normalises raw traces into the dict format RRStep expects.
# ---------------------------------------------------------------------------
class RRTraceStep:
    """Bridge between TraceAnalyser's per-trace context and RRStep.



    TraceAnalyser places the raw trace on ``ctx.trace``.  RRStep.__call__

    expects a traces-format dict with a ``steps`` key.  This adapter

    normalises the trace and delegates to ``RRStep.__call__``.

    """

    requires = frozenset({"trace", "skillbook"})
    provides = frozenset({"reflection"})

    def __init__(self, rr: RRStep) -> None:
        self.rr = rr

    def __call__(self, ctx: ACEStepContext) -> ACEStepContext:
        trace = ctx.trace
        # If the trace is already a traces-format dict, pass it through.
        # Otherwise wrap it so the sandbox can access it via traces["steps"].
        if isinstance(trace, dict) and "steps" in trace:
            traces_dict = trace
        else:
            traces_dict = {
                "question": str(trace.get("id", "")) if isinstance(trace, dict) else "",
                "steps": [trace],
            }
        return self.rr(ctx.replace(trace=traces_dict))


def load_traces(traces_dir: Path) -> Dict[str, Any]:
    """Load all trace files into a single batch trace dict.



    All files are combined into one traces-format dict so the REPL agent

    receives every conversation at once and can analyze cross-trace patterns.

    """
    if not traces_dir.exists():
        print(f"Directory not found: {traces_dir}")
        return {}

    steps: List[Dict[str, Any]] = []
    for ext in ("*.json", "*.md", "*.toon"):
        for file_path in sorted(traces_dir.glob(ext)):
            try:
                raw = file_path.read_text(encoding="utf-8")
                content = json.loads(raw) if file_path.suffix == ".json" else raw
                steps.append(
                    {
                        "role": "conversation",
                        "id": file_path.name,
                        "content": content,
                    }
                )
            except Exception as e:
                print(f"Error reading {file_path.name}: {e}")

    print(f"Loaded {len(steps)} traces")
    if not steps:
        return {}

    return {
        "question": f"Analyze {len(steps)} conversation traces",
        "ground_truth": None,
        "feedback": None,
        "steps": steps,
    }


def main():
    parser = argparse.ArgumentParser(
        description="Offline trace analysis — extract strategies into a skillbook"
    )
    parser.add_argument(
        "traces_dir", type=Path, help="Directory containing trace files"
    )
    parser.add_argument(
        "-m",
        "--model",
        default="bedrock/eu.anthropic.claude-sonnet-4-6",
        help="LLM model for analysis",
    )
    parser.add_argument(
        "-t",
        "--threshold",
        type=float,
        default=0.7,
        help="Deduplication similarity threshold (0.0-1.0)",
    )
    parser.add_argument(
        "-e", "--epochs", type=int, default=1, help="Number of passes over all traces"
    )
    parser.add_argument(
        "-i", "--input-skillbook", type=Path, default=None, help="Existing skillbook"
    )
    parser.add_argument(
        "-o", "--output-dir", type=Path, default=None, help="Output directory"
    )
    args = parser.parse_args()

    if not os.getenv("OPENAI_API_KEY"):
        print("WARNING: OPENAI_API_KEY required for deduplication embeddings!")
        return

    # Load all traces into a single batch dict
    batch_trace = load_traces(args.traces_dir)
    if not batch_trace:
        print(f"\nAdd .json, .md, or .toon trace files to {args.traces_dir}/")
        return
    n_traces = len(batch_trace["steps"])

    # Skillbook (existing or empty)
    skillbook = Skillbook()
    if args.input_skillbook and args.input_skillbook.exists():
        skillbook = Skillbook.load_from_file(str(args.input_skillbook))
        print(f"Loaded skillbook: {len(skillbook.skills())} skills")

    # Build PydanticAI-backed roles directly from model strings
    rr = RRStep(
        args.model,
        config=RRConfig(
            max_requests=60,
        ),
        prompt_template=REFLECTOR_RECURSIVE_PROMPT,
    )
    skill_manager = SkillManager(args.model)
    dedup = DeduplicationManager(
        DeduplicationConfig(
            enabled=True,
            similarity_threshold=args.threshold,
            embedding_model="text-embedding-3-small",
        )
    )

    # Build pipeline: RRTraceStep → Update → Dedup
    # (SkillManager mutates the skillbook directly via its tools, so no
    # separate ApplyStep is needed.)
    steps: list[Any] = [RRTraceStep(rr)]
    steps.extend(
        [
            UpdateStep(skill_manager, skillbook),
            DeduplicateStep(dedup, skillbook),
        ]
    )
    analyser = TraceAnalyser(pipeline=Pipeline(steps), skillbook=skillbook)

    print(
        f"\nStarting analysis: {n_traces} traces (single batch), "
        f"epochs={args.epochs}, model={args.model}"
    )
    start = datetime.now()

    # Run — single batch trace through the pipeline
    results = analyser.run([batch_trace], epochs=args.epochs)

    # Surface any pipeline errors (the pipeline catches exceptions silently)
    failed = [r for r in results if r.error is not None]
    if failed:
        print(f"\n{len(failed)}/{len(results)} traces FAILED:")
        for r in failed:
            print(f"  - {r.failed_at}: {r.error}")

    duration = (datetime.now() - start).total_seconds()

    # Save results
    output_dir = args.output_dir or Path(__file__).parent
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

    output_skillbook = output_dir / f"skillbook_{timestamp}.json"
    analyser.save(str(output_skillbook))

    skills = analyser.skillbook.skills()
    print(f"\nCompleted in {duration:.1f}s")
    print(f"Analyzed: {n_traces} traces (single batch) × {args.epochs} epoch(s)")
    print(f"Generated: {len(skills)} skills")
    print(f"Saved to: {output_skillbook}")

    # Markdown export
    output_md = output_dir / f"skills_{timestamp}.md"
    with open(output_md, "w") as f:
        for section, section_skills in groupby(
            sorted(skills, key=lambda s: s.section), key=lambda s: s.section
        ):
            f.write(f"## {section}\n\n")
            for skill in section_skills:
                f.write(f"- {skill.content}\n")
                if skill.justification:
                    f.write(f"  Justification: {skill.justification}\n")
                if skill.evidence:
                    f.write(f"  Evidence: {skill.evidence}\n")
            f.write("\n")
    print(f"Skills: {output_md}")

    if skills:
        print("\nTop skills:")
        for i, skill in enumerate(
            sorted(skills, key=lambda s: s.helpful, reverse=True)[:5], 1
        ):
            print(f"  {i}. [{skill.section}] {skill.content[:80]}...")

    # External agent injection
    injection = wrap_skillbook_for_external_agent(analyser.skillbook)
    if injection:
        output_injection = output_dir / f"external_agent_injection_{timestamp}.txt"
        with open(output_injection, "w") as f:
            f.write(injection)
        print(f"External agent injection: {output_injection}")


if __name__ == "__main__":
    main()