agent-interlude 0.1.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.
- agent_interlude/__init__.py +12 -0
- agent_interlude/__main__.py +12 -0
- agent_interlude/analyze.py +292 -0
- agent_interlude/proxy.py +547 -0
- agent_interlude/report.py +3823 -0
- agent_interlude-0.1.0.dist-info/METADATA +397 -0
- agent_interlude-0.1.0.dist-info/RECORD +10 -0
- agent_interlude-0.1.0.dist-info/WHEEL +4 -0
- agent_interlude-0.1.0.dist-info/entry_points.txt +4 -0
- agent_interlude-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""agent-interlude — capture AI coding agent <-> API traffic for prompt-architecture analysis.
|
|
2
|
+
|
|
3
|
+
This package exposes three entry points (declared in pyproject.toml):
|
|
4
|
+
|
|
5
|
+
agent-interlude → agent_interlude.proxy:main (bundled launcher: proxy + UI)
|
|
6
|
+
agent-interlude-report → agent_interlude.report:main (web UI alone, reads logs)
|
|
7
|
+
agent-interlude-analyze → agent_interlude.analyze:main (text report)
|
|
8
|
+
|
|
9
|
+
Each module is also runnable as `python -m agent_interlude.<name>` for diagnostics.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""`python -m agent_interlude` shortcut → identical to the `agent-interlude` console script.
|
|
2
|
+
|
|
3
|
+
We forward to agent_interlude.proxy.main rather than introduce a separate
|
|
4
|
+
dispatcher so there's only one entrypoint to maintain. Module-form invocation
|
|
5
|
+
stays useful for diagnostics (`python -m agent_interlude --no-ui`) without
|
|
6
|
+
having the entry-point shim on PATH.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from agent_interlude.proxy import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
main()
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Interlude analysis layer (Phase 5).
|
|
2
|
+
|
|
3
|
+
Reads the JSONL captured by proxy.py and reports the prompt architecture of each
|
|
4
|
+
agent: how the system prompt / tools / messages are shaped, which parts of the
|
|
5
|
+
system prompt are a FIXED SKELETON (present in every request) versus DYNAMIC
|
|
6
|
+
SLOTS (vary between requests), and how Claude Code and Codex differ structurally.
|
|
7
|
+
|
|
8
|
+
Decoupled from interception — it only reads files. Pure stdlib.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
agent-interlude-analyze # all .agent-interlude/log-*.jsonl
|
|
12
|
+
agent-interlude-analyze path/to/log.jsonl ... # specific files/globs
|
|
13
|
+
agent-interlude-analyze --agent claude # filter to one agent
|
|
14
|
+
agent-interlude-analyze --max-slots 30 # show more dynamic-slot lines
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import glob
|
|
19
|
+
import json
|
|
20
|
+
import statistics
|
|
21
|
+
import sys
|
|
22
|
+
from collections import Counter, defaultdict
|
|
23
|
+
|
|
24
|
+
# --- field extraction (tolerant of the different wire shapes) ---------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def system_text(rec):
|
|
28
|
+
"""Return (concatenated system text, block_count) or (None, 0)."""
|
|
29
|
+
ex = rec.get("extract") or {}
|
|
30
|
+
s = ex.get("system")
|
|
31
|
+
if s is None:
|
|
32
|
+
return None, 0
|
|
33
|
+
if isinstance(s, str): # codex-responses `instructions`
|
|
34
|
+
return s, 1
|
|
35
|
+
if isinstance(s, list): # claude blocks, or codex-chat system messages
|
|
36
|
+
parts = []
|
|
37
|
+
for b in s:
|
|
38
|
+
if isinstance(b, str):
|
|
39
|
+
parts.append(b)
|
|
40
|
+
elif isinstance(b, dict):
|
|
41
|
+
if isinstance(b.get("text"), str):
|
|
42
|
+
parts.append(b["text"])
|
|
43
|
+
elif "content" in b:
|
|
44
|
+
c = b["content"]
|
|
45
|
+
if isinstance(c, str):
|
|
46
|
+
parts.append(c)
|
|
47
|
+
elif isinstance(c, list):
|
|
48
|
+
for cb in c:
|
|
49
|
+
if isinstance(cb, dict) and isinstance(cb.get("text"), str):
|
|
50
|
+
parts.append(cb["text"])
|
|
51
|
+
return "\n".join(parts), len(s)
|
|
52
|
+
return None, 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def tool_names(rec):
|
|
56
|
+
ex = rec.get("extract") or {}
|
|
57
|
+
tools = ex.get("tools")
|
|
58
|
+
if not isinstance(tools, list):
|
|
59
|
+
return None
|
|
60
|
+
names = []
|
|
61
|
+
for t in tools:
|
|
62
|
+
if not isinstance(t, dict):
|
|
63
|
+
continue
|
|
64
|
+
n = t.get("name")
|
|
65
|
+
if not n and isinstance(t.get("function"), dict):
|
|
66
|
+
n = t["function"].get("name")
|
|
67
|
+
names.append(n or t.get("type") or "<unknown>")
|
|
68
|
+
return names
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tool_schema_key(rec):
|
|
72
|
+
ex = rec.get("extract") or {}
|
|
73
|
+
for t in ex.get("tools") or []:
|
|
74
|
+
if isinstance(t, dict):
|
|
75
|
+
if "input_schema" in t:
|
|
76
|
+
return "input_schema"
|
|
77
|
+
if "parameters" in t:
|
|
78
|
+
return "parameters"
|
|
79
|
+
if isinstance(t.get("function"), dict) and "parameters" in t["function"]:
|
|
80
|
+
return "function.parameters"
|
|
81
|
+
return "n/a"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def message_summary(rec):
|
|
85
|
+
ex = rec.get("extract") or {}
|
|
86
|
+
msgs = ex.get("messages")
|
|
87
|
+
if not isinstance(msgs, list):
|
|
88
|
+
return None
|
|
89
|
+
roles, ctypes = Counter(), Counter()
|
|
90
|
+
for m in msgs:
|
|
91
|
+
if not isinstance(m, dict):
|
|
92
|
+
continue
|
|
93
|
+
roles[m.get("role") or m.get("type") or "?"] += 1
|
|
94
|
+
c = m.get("content")
|
|
95
|
+
if isinstance(c, str):
|
|
96
|
+
ctypes["text"] += 1
|
|
97
|
+
elif isinstance(c, list):
|
|
98
|
+
for cb in c:
|
|
99
|
+
if isinstance(cb, dict):
|
|
100
|
+
ctypes[cb.get("type", "?")] += 1
|
|
101
|
+
return {"count": len(msgs), "roles": roles, "ctypes": ctypes}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- skeleton vs slot detection ---------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def skeleton(distinct_texts):
|
|
108
|
+
"""Line-frequency split across DISTINCT system samples.
|
|
109
|
+
|
|
110
|
+
A line present in every distinct sample is fixed skeleton; a line present in
|
|
111
|
+
some-but-not-all is a dynamic slot.
|
|
112
|
+
"""
|
|
113
|
+
k = len(distinct_texts)
|
|
114
|
+
freq = Counter()
|
|
115
|
+
for t in distinct_texts:
|
|
116
|
+
for ln in set(t.split("\n")):
|
|
117
|
+
freq[ln] += 1
|
|
118
|
+
fixed = [ln for ln, c in freq.items() if c == k]
|
|
119
|
+
dynamic = [ln for ln, c in freq.items() if 0 < c < k]
|
|
120
|
+
return {"distinct": k, "unique_lines": len(freq), "fixed": len(fixed), "dynamic_lines": dynamic}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- reporting ---------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def trunc(s, n=100):
|
|
127
|
+
s = s.replace("\t", " ").strip()
|
|
128
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def analyze_agent(agent, recs, max_slots):
|
|
132
|
+
print(f"\n## Agent: {agent}")
|
|
133
|
+
wires = Counter(r["wire"] for r in recs)
|
|
134
|
+
print(
|
|
135
|
+
f"requests analyzed: {len(recs)} | wire(s): "
|
|
136
|
+
+ ", ".join(f"{w}×{c}" for w, c in wires.items())
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# system ---------------------------------------------------------------
|
|
140
|
+
sys_samples, block_counts = [], []
|
|
141
|
+
for r in recs:
|
|
142
|
+
t, bc = system_text(r)
|
|
143
|
+
if t is not None:
|
|
144
|
+
sys_samples.append(t)
|
|
145
|
+
block_counts.append(bc)
|
|
146
|
+
facts = {"agent": agent}
|
|
147
|
+
print("\n### system")
|
|
148
|
+
if sys_samples:
|
|
149
|
+
sizes = [len(t) for t in sys_samples]
|
|
150
|
+
distinct = list(dict.fromkeys(sys_samples)) # preserve order, dedup
|
|
151
|
+
sk = skeleton(distinct)
|
|
152
|
+
bc_note = (
|
|
153
|
+
f"{statistics.median(block_counts):.0f} block(s)/req"
|
|
154
|
+
if max(block_counts) > 1
|
|
155
|
+
else "single string"
|
|
156
|
+
)
|
|
157
|
+
print(f"- representation : {bc_note}")
|
|
158
|
+
print(
|
|
159
|
+
f"- size (chars) : min={min(sizes)} median={statistics.median(sizes):.0f} max={max(sizes)}"
|
|
160
|
+
)
|
|
161
|
+
print(f"- distinct samples: {sk['distinct']} (of {len(sys_samples)} requests)")
|
|
162
|
+
if sk["distinct"] < 2:
|
|
163
|
+
print(
|
|
164
|
+
"- skeleton/slots : only 1 distinct system seen — capture more "
|
|
165
|
+
"varied sessions to surface dynamic slots"
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
pct = 100 * sk["fixed"] / sk["unique_lines"] if sk["unique_lines"] else 0
|
|
169
|
+
print(f"- skeleton : {sk['fixed']}/{sk['unique_lines']} lines fixed ({pct:.0f}%)")
|
|
170
|
+
print(f"- dynamic slots : {len(sk['dynamic_lines'])} line(s) vary across samples")
|
|
171
|
+
shown = [trunc(ln) for ln in sk["dynamic_lines"] if ln.strip()][:max_slots]
|
|
172
|
+
for ln in shown:
|
|
173
|
+
print(f" • {ln}")
|
|
174
|
+
if len(sk["dynamic_lines"]) > len(shown):
|
|
175
|
+
print(f" … +{len(sk['dynamic_lines']) - len(shown)} more")
|
|
176
|
+
facts.update(
|
|
177
|
+
sys_repr=bc_note, sys_median=int(statistics.median(sizes)), sys_distinct=sk["distinct"]
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
print("- (no system field captured)")
|
|
181
|
+
facts.update(sys_repr="n/a", sys_median=0, sys_distinct=0)
|
|
182
|
+
|
|
183
|
+
# tools ----------------------------------------------------------------
|
|
184
|
+
print("\n### tools")
|
|
185
|
+
name_lists = [tn for r in recs if (tn := tool_names(r)) is not None]
|
|
186
|
+
if name_lists:
|
|
187
|
+
sets = [frozenset(nl) for nl in name_lists]
|
|
188
|
+
union = set().union(*sets)
|
|
189
|
+
always = set.intersection(*[set(s) for s in sets]) if sets else set()
|
|
190
|
+
sometimes = union - always
|
|
191
|
+
key = next((tool_schema_key(r) for r in recs if tool_schema_key(r) != "n/a"), "n/a")
|
|
192
|
+
print(f"- count : {statistics.median(len(nl) for nl in name_lists):.0f} (median)")
|
|
193
|
+
print(f"- schema key : {key}")
|
|
194
|
+
print(f"- always present : {len(always)} | sometimes: {len(sometimes)}")
|
|
195
|
+
print(f"- names : {', '.join(sorted(union))}")
|
|
196
|
+
if sometimes:
|
|
197
|
+
print(f"- varying tools : {', '.join(sorted(sometimes))}")
|
|
198
|
+
facts.update(
|
|
199
|
+
tool_count=int(statistics.median(len(nl) for nl in name_lists)),
|
|
200
|
+
tool_key=key,
|
|
201
|
+
tool_container="top-level `tools`",
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
print("- (no tools captured)")
|
|
205
|
+
facts.update(tool_count=0, tool_key="n/a", tool_container="n/a")
|
|
206
|
+
|
|
207
|
+
# messages -------------------------------------------------------------
|
|
208
|
+
print("\n### messages")
|
|
209
|
+
summaries = [ms for r in recs if (ms := message_summary(r)) is not None]
|
|
210
|
+
if summaries:
|
|
211
|
+
counts = [s["count"] for s in summaries]
|
|
212
|
+
roles, ctypes = Counter(), Counter()
|
|
213
|
+
for s in summaries:
|
|
214
|
+
roles.update(s["roles"])
|
|
215
|
+
ctypes.update(s["ctypes"])
|
|
216
|
+
print(f"- per-request : min={min(counts)} max={max(counts)} items")
|
|
217
|
+
print(f"- roles/types : {dict(roles)}")
|
|
218
|
+
print(f"- content blocks : {dict(ctypes)}")
|
|
219
|
+
else:
|
|
220
|
+
print("- (no messages captured)")
|
|
221
|
+
return facts
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cross_agent(all_facts):
|
|
225
|
+
if len(all_facts) < 2:
|
|
226
|
+
return
|
|
227
|
+
print("\n## Cross-agent comparison")
|
|
228
|
+
rows = [
|
|
229
|
+
("system representation", "sys_repr"),
|
|
230
|
+
("system size (median chars)", "sys_median"),
|
|
231
|
+
("distinct system samples", "sys_distinct"),
|
|
232
|
+
("tool count (median)", "tool_count"),
|
|
233
|
+
("tool schema key", "tool_key"),
|
|
234
|
+
("tool container", "tool_container"),
|
|
235
|
+
]
|
|
236
|
+
agents = [f["agent"] for f in all_facts]
|
|
237
|
+
w = max(28, *(len(r[0]) for r in rows)) + 1
|
|
238
|
+
print(f"{'dimension':<{w}}" + "".join(f"{a:<22}" for a in agents))
|
|
239
|
+
print("-" * (w + 22 * len(agents)))
|
|
240
|
+
for label, key in rows:
|
|
241
|
+
print(f"{label:<{w}}" + "".join(f"{str(f.get(key, '')):<22}" for f in all_facts))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main():
|
|
245
|
+
ap = argparse.ArgumentParser(description="Analyze agent-interlude JSONL captures.")
|
|
246
|
+
ap.add_argument(
|
|
247
|
+
"paths",
|
|
248
|
+
nargs="*",
|
|
249
|
+
default=[".agent-interlude/log-*.jsonl"],
|
|
250
|
+
help="JSONL files or globs (default: .agent-interlude/log-*.jsonl)",
|
|
251
|
+
)
|
|
252
|
+
ap.add_argument("--agent", help="only analyze this agent label")
|
|
253
|
+
ap.add_argument(
|
|
254
|
+
"--max-slots", type=int, default=15, help="max dynamic-slot lines to print per agent"
|
|
255
|
+
)
|
|
256
|
+
args = ap.parse_args()
|
|
257
|
+
|
|
258
|
+
files = sorted({f for p in args.paths for f in glob.glob(p)})
|
|
259
|
+
if not files:
|
|
260
|
+
print(f"No log files matched: {args.paths}", file=sys.stderr)
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
|
|
263
|
+
recs = []
|
|
264
|
+
for f in files:
|
|
265
|
+
with open(f, encoding="utf-8") as fh:
|
|
266
|
+
for line in fh:
|
|
267
|
+
line = line.strip()
|
|
268
|
+
if not line:
|
|
269
|
+
continue
|
|
270
|
+
try:
|
|
271
|
+
recs.append(json.loads(line))
|
|
272
|
+
except ValueError:
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
by_agent = defaultdict(list)
|
|
276
|
+
for r in recs:
|
|
277
|
+
if r.get("extract") is not None and (not args.agent or r.get("agent") == args.agent):
|
|
278
|
+
by_agent[r.get("agent", "?")].append(r)
|
|
279
|
+
|
|
280
|
+
print("# Interlude prompt-architecture report")
|
|
281
|
+
print(f"sources: {len(files)} file(s), {len(recs)} record(s)")
|
|
282
|
+
print(
|
|
283
|
+
"agents : "
|
|
284
|
+
+ ", ".join(f"{a} ({len(v)} analyzable req)" for a, v in sorted(by_agent.items()))
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
facts = [analyze_agent(a, v, args.max_slots) for a, v in sorted(by_agent.items())]
|
|
288
|
+
cross_agent(facts)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
main()
|