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.
@@ -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()