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/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