agentic-programming 0.4.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.
- agentic/__init__.py +48 -0
- agentic/apps/__init__.py +1 -0
- agentic/apps/mini_lesson.py +47 -0
- agentic/cli.py +319 -0
- agentic/context.py +574 -0
- agentic/function.py +232 -0
- agentic/functions/__init__.py +2 -0
- agentic/functions/extract_domain.py +19 -0
- agentic/functions/sentiment.py +17 -0
- agentic/functions/word_count.py +14 -0
- agentic/mcp/__init__.py +1 -0
- agentic/mcp/__main__.py +4 -0
- agentic/mcp/server.py +189 -0
- agentic/meta_functions/__init__.py +17 -0
- agentic/meta_functions/_helpers.py +265 -0
- agentic/meta_functions/create.py +108 -0
- agentic/meta_functions/create_app.py +136 -0
- agentic/meta_functions/create_skill.py +62 -0
- agentic/meta_functions/fix.py +109 -0
- agentic/providers/__init__.py +169 -0
- agentic/providers/anthropic.py +234 -0
- agentic/providers/claude_code.py +327 -0
- agentic/providers/codex.py +275 -0
- agentic/providers/gemini.py +211 -0
- agentic/providers/gemini_cli.py +165 -0
- agentic/providers/openai.py +249 -0
- agentic/runtime.py +232 -0
- agentic_programming-0.4.0.dist-info/LICENSE +21 -0
- agentic_programming-0.4.0.dist-info/METADATA +373 -0
- agentic_programming-0.4.0.dist-info/RECORD +33 -0
- agentic_programming-0.4.0.dist-info/WHEEL +5 -0
- agentic_programming-0.4.0.dist-info/entry_points.txt +2 -0
- agentic_programming-0.4.0.dist-info/top_level.txt +1 -0
agentic/context.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context — execution record for Agentic Functions.
|
|
3
|
+
|
|
4
|
+
The Big Picture:
|
|
5
|
+
Every @agentic_function call creates a Context node. Nodes form a tree
|
|
6
|
+
via parent/children links. The tree is a COMPLETE, IMMUTABLE record of
|
|
7
|
+
everything that happened during execution.
|
|
8
|
+
|
|
9
|
+
Two concerns, fully separated:
|
|
10
|
+
|
|
11
|
+
1. RECORDING — automatic, unconditional. Every function call gets a node.
|
|
12
|
+
All parameters, outputs, errors, LLM I/O are captured. Nothing is
|
|
13
|
+
ever deleted or modified after recording.
|
|
14
|
+
|
|
15
|
+
2. READING — on-demand, selective. When a function needs to call an LLM,
|
|
16
|
+
summarize() queries the tree and returns a text string containing
|
|
17
|
+
only the relevant parts. What to include is configured per-function
|
|
18
|
+
via the @agentic_function decorator's `summarize` parameter.
|
|
19
|
+
|
|
20
|
+
This separation means:
|
|
21
|
+
- Recording is never affected by how data is read later
|
|
22
|
+
- Different functions can read the SAME tree differently
|
|
23
|
+
- The full history is always available for debugging/saving
|
|
24
|
+
|
|
25
|
+
Tree Example:
|
|
26
|
+
root
|
|
27
|
+
├── navigate("login") → root/navigate_0
|
|
28
|
+
│ ├── observe("find login") → root/navigate_0/observe_0
|
|
29
|
+
│ │ ├── run_ocr(img) → root/navigate_0/observe_0/run_ocr_0
|
|
30
|
+
│ │ └── detect_all(img) → root/navigate_0/observe_0/detect_all_0
|
|
31
|
+
│ ├── act("click login") → root/navigate_0/act_0
|
|
32
|
+
│ └── verify("check result") → root/navigate_0/verify_0
|
|
33
|
+
└── navigate("settings") → root/navigate_1
|
|
34
|
+
└── ...
|
|
35
|
+
|
|
36
|
+
Paths are auto-computed: {parent_path}/{name}_{index_among_same_name_siblings}
|
|
37
|
+
|
|
38
|
+
See also:
|
|
39
|
+
function.py — @agentic_function decorator (creates nodes, manages the tree)
|
|
40
|
+
runtime.py — runtime.exec() (calls the LLM, reads/writes Context nodes)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import os
|
|
46
|
+
import time
|
|
47
|
+
import json
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from typing import Any, Callable, Optional
|
|
50
|
+
from contextvars import ContextVar
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Global state
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Currently active Context node. @agentic_function sets on entry, resets on exit.
|
|
57
|
+
_current_ctx: ContextVar[Optional["Context"]] = ContextVar(
|
|
58
|
+
"_current_ctx", default=None
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Context — one node in the execution tree
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Context:
|
|
70
|
+
"""
|
|
71
|
+
One execution record = one function call.
|
|
72
|
+
|
|
73
|
+
Users never create or modify Context objects directly.
|
|
74
|
+
@agentic_function creates them automatically, and runtime.exec()
|
|
75
|
+
fills in the LLM-related fields.
|
|
76
|
+
|
|
77
|
+
Fields are grouped by who sets them:
|
|
78
|
+
|
|
79
|
+
Set by @agentic_function (on entry):
|
|
80
|
+
name, prompt, params, parent, children, render, compress,
|
|
81
|
+
start_time, _summarize_kwargs
|
|
82
|
+
|
|
83
|
+
Set by @agentic_function (on exit):
|
|
84
|
+
output OR error, status, end_time
|
|
85
|
+
|
|
86
|
+
Set by runtime.exec() (during execution):
|
|
87
|
+
raw_reply
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# --- Identity & input ---
|
|
91
|
+
name: str = "" # Function name (from fn.__name__)
|
|
92
|
+
prompt: str = "" # Docstring (from fn.__doc__) — doubles as LLM prompt
|
|
93
|
+
params: dict = field(default_factory=dict) # Call arguments
|
|
94
|
+
|
|
95
|
+
# --- Execution result ---
|
|
96
|
+
output: Any = None # Return value (set on success)
|
|
97
|
+
error: str = "" # Error message (set on exception)
|
|
98
|
+
status: str = "running" # "running" → "success" or "error"
|
|
99
|
+
|
|
100
|
+
# --- Tree structure ---
|
|
101
|
+
children: list = field(default_factory=list) # Child nodes (sub-calls)
|
|
102
|
+
parent: Optional["Context"] = field(default=None, repr=False)
|
|
103
|
+
|
|
104
|
+
# --- Timing ---
|
|
105
|
+
start_time: float = 0.0
|
|
106
|
+
end_time: float = 0.0
|
|
107
|
+
|
|
108
|
+
# --- Display settings (set via @agentic_function decorator) ---
|
|
109
|
+
|
|
110
|
+
render: str = "summary"
|
|
111
|
+
# Default rendering level when others view this node via summarize().
|
|
112
|
+
#
|
|
113
|
+
# Five levels, from most to least verbose:
|
|
114
|
+
# "trace" — prompt + full I/O + raw LLM reply + error
|
|
115
|
+
# "detail" — name(params) → status duration | input | output
|
|
116
|
+
# "summary" — name: output_snippet duration (DEFAULT)
|
|
117
|
+
# "result" — just the return value as JSON
|
|
118
|
+
# "silent" — not shown at all
|
|
119
|
+
#
|
|
120
|
+
# This is a DEFAULT hint. Callers can override it:
|
|
121
|
+
# ctx.summarize(level="detail") ← forces all nodes to render as "detail"
|
|
122
|
+
|
|
123
|
+
compress: bool = False
|
|
124
|
+
# When True: after this function completes, summarize() renders only
|
|
125
|
+
# this node's own result — its children are NOT expanded.
|
|
126
|
+
#
|
|
127
|
+
# Use for high-level orchestrating functions. Example:
|
|
128
|
+
# navigate(compress=True) has children observe, act, verify.
|
|
129
|
+
# After navigate finishes, others see "navigate: {success: true}"
|
|
130
|
+
# without the 10 sub-steps inside.
|
|
131
|
+
#
|
|
132
|
+
# The children are still fully recorded in the tree — compress only
|
|
133
|
+
# affects how summarize() renders this node. tree() and save() always
|
|
134
|
+
# show the complete structure.
|
|
135
|
+
|
|
136
|
+
# --- LLM call record (set by runtime.exec()) ---
|
|
137
|
+
raw_reply: str = None # Raw LLM response text (None = not called yet)
|
|
138
|
+
attempts: list = field(default_factory=list)
|
|
139
|
+
# Each exec() attempt is recorded here, whether it succeeds or fails:
|
|
140
|
+
# {"attempt": 1, "reply": "LLM response" or None, "error": "error msg" or None}
|
|
141
|
+
|
|
142
|
+
# --- Internal: decorator config ---
|
|
143
|
+
_summarize_kwargs: Optional[dict] = field(default=None, repr=False)
|
|
144
|
+
# The `summarize` dict from @agentic_function(summarize={...}).
|
|
145
|
+
# runtime.exec() uses this: ctx.summarize(**ctx._summarize_kwargs)
|
|
146
|
+
# If None, runtime.exec() calls ctx.summarize() with defaults (see all).
|
|
147
|
+
|
|
148
|
+
# --- Optional: user-provided render function ---
|
|
149
|
+
summary_fn: Optional[Callable] = field(default=None, repr=False)
|
|
150
|
+
# If set, _render() calls this instead of the built-in formatting.
|
|
151
|
+
# Signature: fn(ctx: Context) -> str
|
|
152
|
+
|
|
153
|
+
# ==================================================================
|
|
154
|
+
# PATH — auto-computed tree address
|
|
155
|
+
# ==================================================================
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def path(self) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Auto-computed address in the tree.
|
|
161
|
+
|
|
162
|
+
Format: parent_path/name_index
|
|
163
|
+
Example: "root/navigate_0/observe_1/run_ocr_0"
|
|
164
|
+
|
|
165
|
+
The index counts same-name siblings under the same parent.
|
|
166
|
+
observe_0 = first observe, observe_1 = second observe, etc.
|
|
167
|
+
"""
|
|
168
|
+
if not self.parent:
|
|
169
|
+
return self.name
|
|
170
|
+
idx = 0
|
|
171
|
+
for c in self.parent.children:
|
|
172
|
+
if c is self:
|
|
173
|
+
break
|
|
174
|
+
if c.name == self.name:
|
|
175
|
+
idx += 1
|
|
176
|
+
return f"{self.parent.path}/{self.name}_{idx}"
|
|
177
|
+
|
|
178
|
+
def _depth(self) -> int:
|
|
179
|
+
"""How deep this node is in the tree. Root = 1."""
|
|
180
|
+
d = 1
|
|
181
|
+
node = self.parent
|
|
182
|
+
while node:
|
|
183
|
+
d += 1
|
|
184
|
+
node = node.parent
|
|
185
|
+
return d
|
|
186
|
+
|
|
187
|
+
def _indent(self) -> str:
|
|
188
|
+
"""Indentation string for this node (4 spaces per level)."""
|
|
189
|
+
return " " * self._depth()
|
|
190
|
+
|
|
191
|
+
def _call_path(self) -> str:
|
|
192
|
+
"""Full call path like login_flow.navigate_to.observe_screen."""
|
|
193
|
+
parts = []
|
|
194
|
+
node = self
|
|
195
|
+
while node:
|
|
196
|
+
parts.append(node.name)
|
|
197
|
+
node = node.parent
|
|
198
|
+
return ".".join(reversed(parts))
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def duration_ms(self) -> float:
|
|
202
|
+
"""Execution time in milliseconds. 0 if still running."""
|
|
203
|
+
if self.end_time and self.start_time:
|
|
204
|
+
return (self.end_time - self.start_time) * 1000
|
|
205
|
+
return 0.0
|
|
206
|
+
|
|
207
|
+
# ==================================================================
|
|
208
|
+
# SUMMARIZE — query the tree for LLM context
|
|
209
|
+
# ==================================================================
|
|
210
|
+
|
|
211
|
+
def summarize(
|
|
212
|
+
self,
|
|
213
|
+
depth: int = -1,
|
|
214
|
+
siblings: int = -1,
|
|
215
|
+
level: Optional[str] = None,
|
|
216
|
+
include: Optional[list] = None,
|
|
217
|
+
exclude: Optional[list] = None,
|
|
218
|
+
branch: Optional[list] = None,
|
|
219
|
+
max_tokens: Optional[int] = None,
|
|
220
|
+
) -> str:
|
|
221
|
+
"""
|
|
222
|
+
Read from the Context tree and produce a text string for LLM input.
|
|
223
|
+
|
|
224
|
+
This is the ONLY way Context data flows into LLM calls.
|
|
225
|
+
runtime.exec() calls this automatically using the decorator's config.
|
|
226
|
+
|
|
227
|
+
Default behavior (all defaults):
|
|
228
|
+
- Shows ALL ancestors (root → parent chain)
|
|
229
|
+
- Shows ALL same-level siblings that completed before this node
|
|
230
|
+
- Does NOT show siblings' children (each sibling is one line)
|
|
231
|
+
- Does NOT show the current node itself
|
|
232
|
+
|
|
233
|
+
This default guarantees maximum prompt cache hit rate: every call
|
|
234
|
+
sees the previous call's context as a prefix, plus new content
|
|
235
|
+
appended at the end.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
depth: How many ancestor levels to show.
|
|
239
|
+
-1 = all (default), 0 = none, 1 = parent only, N = up to N levels.
|
|
240
|
+
|
|
241
|
+
siblings: How many previous siblings to show.
|
|
242
|
+
-1 = all (default), 0 = none, N = last N siblings.
|
|
243
|
+
When N is set, keeps the N most recent (closest to current).
|
|
244
|
+
|
|
245
|
+
level: Override render level for ALL nodes in the output.
|
|
246
|
+
If None, each node uses its own `render` setting.
|
|
247
|
+
Values: "trace" / "detail" / "summary" / "result" / "silent"
|
|
248
|
+
|
|
249
|
+
include: Path whitelist. Only show nodes whose path matches.
|
|
250
|
+
Supports * wildcard: "root/navigate_0/*" matches all children.
|
|
251
|
+
|
|
252
|
+
exclude: Path blacklist. Hide nodes whose path matches.
|
|
253
|
+
Supports * wildcard.
|
|
254
|
+
|
|
255
|
+
branch: List of node names whose children should be expanded.
|
|
256
|
+
By default, siblings are shown as one line (no children).
|
|
257
|
+
branch=["observe"] would expand observe nodes to show
|
|
258
|
+
their run_ocr/detect_all children.
|
|
259
|
+
Respects compress: compressed nodes are NOT expanded.
|
|
260
|
+
|
|
261
|
+
max_tokens: Approximate token budget. When exceeded, drops the
|
|
262
|
+
oldest siblings first. Uses len(text)/4 as token estimate.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
A string ready to be injected into an LLM prompt.
|
|
266
|
+
Empty string if nothing to show.
|
|
267
|
+
|
|
268
|
+
Examples:
|
|
269
|
+
ctx.summarize() # see everything (default)
|
|
270
|
+
ctx.summarize(depth=1, siblings=3) # parent + last 3 siblings
|
|
271
|
+
ctx.summarize(depth=0, siblings=0) # nothing (isolated mode)
|
|
272
|
+
ctx.summarize(level="detail") # force all nodes to detail
|
|
273
|
+
ctx.summarize(include=["root/navigate_0/*"]) # only navigate's children
|
|
274
|
+
ctx.summarize(branch=["observe"]) # expand observe's children
|
|
275
|
+
ctx.summarize(max_tokens=1000) # with token budget
|
|
276
|
+
"""
|
|
277
|
+
lines = ["Execution Context (most recent call last):"]
|
|
278
|
+
|
|
279
|
+
# --- Ancestors: root → ... → parent ---
|
|
280
|
+
# Collect ancestors from root to parent, each indented by depth
|
|
281
|
+
if depth != 0 and self.parent and self.parent.name:
|
|
282
|
+
ancestors = []
|
|
283
|
+
node = self.parent
|
|
284
|
+
while node and node.name:
|
|
285
|
+
ancestors.append(node)
|
|
286
|
+
node = node.parent
|
|
287
|
+
if depth > 0 and len(ancestors) >= depth:
|
|
288
|
+
break
|
|
289
|
+
for a in reversed(ancestors):
|
|
290
|
+
if not _node_allowed(a, include, exclude):
|
|
291
|
+
continue
|
|
292
|
+
lines.append(a._render_traceback(" " * a._depth(), level))
|
|
293
|
+
|
|
294
|
+
# --- Siblings: previous same-level nodes ---
|
|
295
|
+
if self.parent:
|
|
296
|
+
sibling_indent = " " * self._depth()
|
|
297
|
+
|
|
298
|
+
sibling_parts = []
|
|
299
|
+
for c in self.parent.children:
|
|
300
|
+
if c is self:
|
|
301
|
+
break
|
|
302
|
+
if c.status == "running":
|
|
303
|
+
continue
|
|
304
|
+
if not _node_allowed(c, include, exclude):
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
render_level = level or c.render
|
|
308
|
+
if render_level == "silent":
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
rendered = c._render_traceback(sibling_indent, render_level)
|
|
312
|
+
|
|
313
|
+
if branch and c.name in branch:
|
|
314
|
+
if not (c.compress and c.status != "running"):
|
|
315
|
+
rendered += "\n" + c._render_branch_traceback(
|
|
316
|
+
render_level, c._depth() + 1, include, exclude,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
sibling_parts.append(rendered)
|
|
320
|
+
|
|
321
|
+
if siblings >= 0:
|
|
322
|
+
sibling_parts = sibling_parts[-siblings:] if siblings > 0 else []
|
|
323
|
+
|
|
324
|
+
if max_tokens is not None:
|
|
325
|
+
total = sum(len(s) for s in sibling_parts)
|
|
326
|
+
while sibling_parts and total > max_tokens * 4:
|
|
327
|
+
removed = sibling_parts.pop(0)
|
|
328
|
+
total -= len(removed)
|
|
329
|
+
|
|
330
|
+
lines.extend(sibling_parts)
|
|
331
|
+
|
|
332
|
+
# --- Current call at its natural indent ---
|
|
333
|
+
self_indent = " " * self._depth()
|
|
334
|
+
lines.append(f"{self_indent}- {self._call_path()}({_fmt_params(self.params)}) <-- Current Call")
|
|
335
|
+
if self.prompt:
|
|
336
|
+
lines.append(f'{self_indent} """{self.prompt}"""')
|
|
337
|
+
|
|
338
|
+
return "\n".join(lines)
|
|
339
|
+
|
|
340
|
+
# ==================================================================
|
|
341
|
+
# RENDERING — format a single node as text
|
|
342
|
+
# ==================================================================
|
|
343
|
+
|
|
344
|
+
def _render_traceback(self, indent: str, level: str) -> str:
|
|
345
|
+
"""
|
|
346
|
+
Render this node in traceback format.
|
|
347
|
+
|
|
348
|
+
Level controls how much detail:
|
|
349
|
+
- "summary" (default): name, docstring, params, output, status, duration
|
|
350
|
+
- "detail": summary + LLM raw_reply
|
|
351
|
+
- "result": name + return value only
|
|
352
|
+
- "silent": empty string
|
|
353
|
+
"""
|
|
354
|
+
if self.summary_fn:
|
|
355
|
+
return self.summary_fn(self)
|
|
356
|
+
|
|
357
|
+
if level == "silent":
|
|
358
|
+
return ""
|
|
359
|
+
|
|
360
|
+
dur = f", {self.duration_ms:.0f}ms" if self.end_time else ""
|
|
361
|
+
lines = [f"{indent}- {self._call_path()}({_fmt_params(self.params)})"]
|
|
362
|
+
|
|
363
|
+
if level == "result":
|
|
364
|
+
if self.output is not None:
|
|
365
|
+
lines.append(f"{indent} return {_json(self.output, 200)}")
|
|
366
|
+
return "\n".join(lines)
|
|
367
|
+
|
|
368
|
+
# docstring as annotation (not "Purpose:")
|
|
369
|
+
if self.prompt:
|
|
370
|
+
lines.append(f'{indent} """{self.prompt}"""')
|
|
371
|
+
|
|
372
|
+
if self.output is not None:
|
|
373
|
+
lines.append(f"{indent} return {_json(self.output, 300)}")
|
|
374
|
+
if self.error:
|
|
375
|
+
lines.append(f"{indent} Error: {self.error}")
|
|
376
|
+
|
|
377
|
+
# Show failed attempts if any
|
|
378
|
+
failed_attempts = [a for a in self.attempts if a.get("error")]
|
|
379
|
+
if failed_attempts:
|
|
380
|
+
for a in failed_attempts:
|
|
381
|
+
lines.append(f"{indent} [Attempt {a['attempt']} FAILED] {a['error']}")
|
|
382
|
+
if a.get("reply"):
|
|
383
|
+
lines.append(f"{indent} Reply was: {str(a['reply'])[:200]}")
|
|
384
|
+
|
|
385
|
+
lines.append(f"{indent} Status: {self.status}{dur}")
|
|
386
|
+
|
|
387
|
+
# detail adds LLM interaction
|
|
388
|
+
if level == "detail" and self.raw_reply is not None:
|
|
389
|
+
lines.append(f"{indent} LLM reply: {self.raw_reply[:500]}")
|
|
390
|
+
|
|
391
|
+
return "\n".join(lines)
|
|
392
|
+
|
|
393
|
+
def _render_branch_traceback(
|
|
394
|
+
self, level: Optional[str], depth: int = 1,
|
|
395
|
+
include: Optional[list] = None, exclude: Optional[list] = None,
|
|
396
|
+
) -> str:
|
|
397
|
+
"""Render children recursively in traceback format."""
|
|
398
|
+
lines = []
|
|
399
|
+
for c in self.children:
|
|
400
|
+
if not _node_allowed(c, include, exclude):
|
|
401
|
+
continue
|
|
402
|
+
render_level = level or c.render
|
|
403
|
+
if render_level != "silent":
|
|
404
|
+
indent = " " * depth
|
|
405
|
+
lines.append(c._render_traceback(indent, render_level))
|
|
406
|
+
if c.children and not (c.compress and c.status != "running"):
|
|
407
|
+
lines.append(c._render_branch_traceback(render_level, depth + 1, include, exclude))
|
|
408
|
+
return "\n".join(lines)
|
|
409
|
+
|
|
410
|
+
# --- Legacy _render for tree()/traceback() compatibility ---
|
|
411
|
+
def _render(self, level: str) -> str:
|
|
412
|
+
"""Legacy render for backward compat. Delegates to _render_traceback."""
|
|
413
|
+
return self._render_traceback("", level)
|
|
414
|
+
|
|
415
|
+
def _render_branch(
|
|
416
|
+
self, level: Optional[str], indent: int = 1,
|
|
417
|
+
include: Optional[list] = None, exclude: Optional[list] = None,
|
|
418
|
+
) -> str:
|
|
419
|
+
"""Legacy branch render for backward compat."""
|
|
420
|
+
return self._render_branch_traceback(level, indent, include, exclude)
|
|
421
|
+
|
|
422
|
+
# ==================================================================
|
|
423
|
+
# TREE INSPECTION — human-readable views
|
|
424
|
+
# ==================================================================
|
|
425
|
+
|
|
426
|
+
def tree(self, indent: int = 0) -> str:
|
|
427
|
+
"""
|
|
428
|
+
Full tree view for debugging. Shows ALL nodes regardless of
|
|
429
|
+
render/compress settings.
|
|
430
|
+
|
|
431
|
+
Example output:
|
|
432
|
+
root …
|
|
433
|
+
navigate ✓ 3200ms → {'success': True}
|
|
434
|
+
observe ✓ 1200ms → {'found': True}
|
|
435
|
+
act ✓ 820ms → {'clicked': True}
|
|
436
|
+
"""
|
|
437
|
+
prefix = " " * indent
|
|
438
|
+
dur = f" {self.duration_ms:.0f}ms" if self.end_time else ""
|
|
439
|
+
icon = "✓" if self.status == "success" else "✗" if self.status == "error" else "…"
|
|
440
|
+
out = f" → {self.output}" if self.output is not None else ""
|
|
441
|
+
err = f" ERROR: {self.error}" if self.error else ""
|
|
442
|
+
line = f"{prefix}{self.name} {icon}{dur}{out}{err}"
|
|
443
|
+
lines = [line]
|
|
444
|
+
for c in self.children:
|
|
445
|
+
lines.append(c.tree(indent + 1))
|
|
446
|
+
return "\n".join(lines)
|
|
447
|
+
|
|
448
|
+
def traceback(self) -> str:
|
|
449
|
+
"""
|
|
450
|
+
Error traceback in a format similar to Python's.
|
|
451
|
+
|
|
452
|
+
Example output:
|
|
453
|
+
Agentic Traceback:
|
|
454
|
+
navigate(target="login") → error, 4523ms
|
|
455
|
+
observe(task="find login") → success, 1200ms
|
|
456
|
+
act(target="login") → error, 820ms
|
|
457
|
+
error: element not interactable
|
|
458
|
+
"""
|
|
459
|
+
lines = ["Agentic Traceback:"]
|
|
460
|
+
self._traceback_lines(lines, indent=1)
|
|
461
|
+
return "\n".join(lines)
|
|
462
|
+
|
|
463
|
+
def _traceback_lines(self, lines: list, indent: int):
|
|
464
|
+
prefix = " " * indent
|
|
465
|
+
dur = f", {self.duration_ms:.0f}ms" if self.end_time else ""
|
|
466
|
+
lines.append(f"{prefix}{self.name}({_fmt_params(self.params)}) → {self.status}{dur}")
|
|
467
|
+
if self.error:
|
|
468
|
+
lines.append(f"{prefix} error: {self.error}")
|
|
469
|
+
for c in self.children:
|
|
470
|
+
c._traceback_lines(lines, indent + 1)
|
|
471
|
+
|
|
472
|
+
# ==================================================================
|
|
473
|
+
# PERSISTENCE — save the tree to disk
|
|
474
|
+
# ==================================================================
|
|
475
|
+
|
|
476
|
+
def save(self, path: str | os.PathLike[str]):
|
|
477
|
+
"""
|
|
478
|
+
Save the full tree to a file.
|
|
479
|
+
|
|
480
|
+
.md → human-readable tree view (same as tree())
|
|
481
|
+
.jsonl → one JSON object per node, machine-readable
|
|
482
|
+
|
|
483
|
+
Accepts both plain strings and pathlib.Path / os.PathLike objects.
|
|
484
|
+
Raises ValueError for unsupported extensions.
|
|
485
|
+
"""
|
|
486
|
+
path_str = os.fspath(path)
|
|
487
|
+
os.makedirs(os.path.dirname(os.path.abspath(path_str)), exist_ok=True)
|
|
488
|
+
if path_str.endswith(".md"):
|
|
489
|
+
with open(path_str, "w") as f:
|
|
490
|
+
f.write(self.tree())
|
|
491
|
+
elif path_str.endswith(".jsonl"):
|
|
492
|
+
with open(path_str, "w") as f:
|
|
493
|
+
for record in self._to_records():
|
|
494
|
+
f.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
|
495
|
+
else:
|
|
496
|
+
raise ValueError(
|
|
497
|
+
f"Unsupported file extension: {path_str}. Use .md or .jsonl."
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def _to_records(self, tree_depth: int = 0) -> list[dict]:
|
|
501
|
+
"""Flatten the tree into a list of dicts for JSONL export."""
|
|
502
|
+
records = [{
|
|
503
|
+
"depth": tree_depth,
|
|
504
|
+
"path": self.path,
|
|
505
|
+
"name": self.name,
|
|
506
|
+
"prompt": self.prompt,
|
|
507
|
+
"params": self.params,
|
|
508
|
+
"output": self.output,
|
|
509
|
+
"raw_reply": self.raw_reply,
|
|
510
|
+
"attempts": self.attempts,
|
|
511
|
+
"error": self.error,
|
|
512
|
+
"status": self.status,
|
|
513
|
+
"render": self.render,
|
|
514
|
+
"compress": self.compress,
|
|
515
|
+
"duration_ms": self.duration_ms,
|
|
516
|
+
}]
|
|
517
|
+
for c in self.children:
|
|
518
|
+
records.extend(c._to_records(tree_depth + 1))
|
|
519
|
+
return records
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ======================================================================
|
|
523
|
+
# Internal helpers
|
|
524
|
+
# ======================================================================
|
|
525
|
+
|
|
526
|
+
def _node_allowed(node: Context, include: Optional[list], exclude: Optional[list]) -> bool:
|
|
527
|
+
"""Check if a node passes include/exclude path filters.
|
|
528
|
+
|
|
529
|
+
include and exclude are applied together:
|
|
530
|
+
1. If include is set, node must match at least one include pattern
|
|
531
|
+
2. If exclude is set, node must not match any exclude pattern
|
|
532
|
+
Both conditions must be satisfied.
|
|
533
|
+
"""
|
|
534
|
+
allowed = True
|
|
535
|
+
if include is not None:
|
|
536
|
+
allowed = any(_path_matches(node.path, p) for p in include)
|
|
537
|
+
if allowed and exclude is not None:
|
|
538
|
+
allowed = not any(_path_matches(node.path, p) for p in exclude)
|
|
539
|
+
return allowed
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _path_matches(path: str, pattern: str) -> bool:
|
|
543
|
+
"""Match a node path against a pattern. Supports * wildcard and /* suffix.
|
|
544
|
+
|
|
545
|
+
foo/* matches children of foo (e.g. foo/bar_0), NOT foo itself.
|
|
546
|
+
"""
|
|
547
|
+
if pattern.endswith("/*"):
|
|
548
|
+
prefix = pattern[:-2]
|
|
549
|
+
return path.startswith(prefix + "/")
|
|
550
|
+
if "*" in pattern:
|
|
551
|
+
import fnmatch
|
|
552
|
+
return fnmatch.fnmatch(path, pattern)
|
|
553
|
+
return path == pattern
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _fmt_params(params: dict) -> str:
|
|
557
|
+
"""Format function parameters for display. Truncates long values."""
|
|
558
|
+
if not params:
|
|
559
|
+
return ""
|
|
560
|
+
parts = []
|
|
561
|
+
for k, v in params.items():
|
|
562
|
+
v_str = repr(v) if isinstance(v, str) else json.dumps(v, ensure_ascii=False, default=str)
|
|
563
|
+
if len(v_str) > 50:
|
|
564
|
+
v_str = v_str[:47] + "..."
|
|
565
|
+
parts.append(f"{k}={v_str}")
|
|
566
|
+
return ", ".join(parts)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _json(obj: Any, max_len: int = 0) -> str:
|
|
570
|
+
"""Serialize to JSON string, optionally truncated."""
|
|
571
|
+
s = json.dumps(obj, ensure_ascii=False, default=str)
|
|
572
|
+
if max_len and len(s) > max_len:
|
|
573
|
+
return s[:max_len - 3] + "..."
|
|
574
|
+
return s
|