Skip to content

Commit bcd4e63

Browse files
authored
Merge #10: 97 on Bud Agent Flow: six rules narrowed, the-call, and turns lowered for Pydantic AI
97 on Bud Agent Flow: six rules narrowed, the-call, and turns lowered for Pydantic AI
2 parents 33b9800 + 2ac5439 commit bcd4e63

22 files changed

Lines changed: 590 additions & 56 deletions

‎CHANGELOG.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,30 @@
33
What changed in PACT, newest first. Each entry says what an author can now write, or what now
44
holds that did not; the commits are listed under it for whoever wants the detail.
55

6+
## 2026-11 — law 3 as the Bud Agent Flow corpus reads it (11 November)
7+
8+
Running the new checker over Bud Agent Flow's 168 shipped trees refused 29 of them, all with
9+
`loader/repeated-with-no-claim` or `loader/a-key-that-changes-every-attempt`. Read one by one,
10+
three of the rule's readings were wider than law 3, and each is narrowed here with a test:
11+
12+
- **A `together` is not repetition by itself.** Its branches are different stages, each run
13+
once: an email in one branch and a chat message in the other are two calls. What repeats is
14+
the *same* action called from two branches side by side, and that is still refused
15+
(`two_branches_calling_the_same_write_do_it_twice`). The old reading also replaced the
16+
context around a `together`, so a `together` inside a `repeat` lost the repeat; it now keeps
17+
it (`a_together_inside_a_repeat_still_runs_every_round`).
18+
- **The stock memory tools are not held by the rule.** `bud/remember` and `bud/forget` in a
19+
`repeat` are how a round leaves what it learned for the next (Reflexion's buffer); the memory
20+
is the workspace's own (`a_round_may_keep_what_it_learned`).
21+
- **A key bound from the round's own output is a warning.** The author wrote a claim, and a
22+
retry of one round produces the same output and so the same key. Whether two *different*
23+
rounds aim at the same thing is an intent the checker cannot see; a rolling plan whose every
24+
round posts a new update is the other reading, and the corpus has nine of them. A call with
25+
no claim at all inside a `repeat` or a retry is still an error.
26+
27+
The other trees were real defects (writes retried or repeated with no claim) and were fixed in
28+
Bud Agent Flow by giving each action the key its business has.
29+
630
## 2026-11 — the seven primitives, built (10 November)
731

832
`docs/97-PRIMITIVES.md` names seven building blocks (Shape, Origin, Store, Actor, Flow, Check,

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -665,11 +665,11 @@ framework is an extra.
665665
|---|---|
666666
| **Design** | Thesis, 28 binding decisions, FRD (120 requirements), 60-row refusal ledger — complete |
667667
| **Research** | 14 source-grounded studies, ~15,750 lines, over 140 repos (~15 GB) + 57 papers |
668-
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4683 tests (1556 Rust + 3127 adapter), clippy clean, TypeScript type-checked** |
668+
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4695 tests (1561 Rust + 3134 adapter), clippy clean, TypeScript type-checked** |
669669
| **Adapters** | **All 7 named targets**, proven against one shared conformance suite |
670670

671671
```bash
672-
./scripts/test-all.sh # 4683 tests, Rust + 7 adapters, fully offline
672+
./scripts/test-all.sh # 4695 tests, Rust + 7 adapters, fully offline
673673
```
674674

675675
**What is still owed** is not hidden — it lives in

‎adapters/python/src/pact_adapters/at_most_once.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@
7979
if TYPE_CHECKING:
8080
from .claims import ClaimStore
8181

82+
#: `same-request-key: the-call` (law 3, narrowed 11 November 2026): the call's
83+
#: own place in the run is its identity (`RequestKeys.by_the_call`).
84+
THE_CALL = "the-call"
85+
8286
#: What `step.tool.cancelled` carries when a duplicate was withheld. One
8387
#: spelling, because the event's `reason` is read by watches and by anyone
8488
#: reconstructing a run, and two spellings of one outcome is how a reader comes
@@ -122,6 +126,11 @@ class RequestKeys:
122126
#: How long one call's claim lasts, in seconds, per action, from
123127
#: `claim-lasts:` (docs/98 WP-7). Absent: as long as its scope.
124128
leases: dict[tuple[str, str], float] = field(default_factory=dict)
129+
#: The actions that say `same-request-key: the-call`: the call's own place
130+
#: in the run is its identity, which a host sends to the server as the
131+
#: request's idempotency key. Nothing here holds them: two calls at two
132+
#: places are two calls, and a retry of one is the host's to identify.
133+
by_the_call: frozenset[tuple[str, str]] = frozenset()
125134

126135
def __bool__(self) -> bool:
127136
return bool(self.keys)
@@ -140,6 +149,7 @@ def from_document(doc: Mapping[str, Any], agent_key: str) -> "RequestKeys":
140149
scopes: dict[tuple[str, str], str] = {}
141150
leases: dict[tuple[str, str], float] = {}
142151
actions: dict[str, tuple[str, ...]] = {}
152+
by_the_call: set[tuple[str, str]] = set()
143153
for tool, spec in sorted((doc.get("tools") or {}).items()):
144154
if tool not in used or not isinstance(spec, Mapping):
145155
continue
@@ -151,6 +161,9 @@ def from_document(doc: Mapping[str, Any], agent_key: str) -> "RequestKeys":
151161
if not isinstance(body, Mapping):
152162
continue
153163
named = str(body.get("same-request-key") or "").strip()
164+
if named == THE_CALL:
165+
by_the_call.add((str(tool), str(action)))
166+
continue
154167
if named:
155168
keys[(str(tool), str(action))] = named
156169
across = str(body.get("same-request-key-across") or "").strip()
@@ -159,7 +172,9 @@ def from_document(doc: Mapping[str, Any], agent_key: str) -> "RequestKeys":
159172
lease = seconds(body.get("claim-lasts"))
160173
if lease is not None:
161174
leases[(str(tool), str(action))] = lease
162-
return RequestKeys(keys=keys, actions=actions, scopes=scopes, leases=leases)
175+
return RequestKeys(
176+
keys=keys, actions=actions, scopes=scopes, leases=leases, by_the_call=frozenset(by_the_call)
177+
)
163178

164179
def action_of(self, tool: str, args: Mapping[str, Any]) -> str | None:
165180
"""Which action this call names, or `None` when nothing can say.
@@ -405,6 +420,14 @@ def unenforced(self) -> tuple[str, ...]:
405420
nobody wrote.
406421
"""
407422
said = {**self.cannot_tell, **self.cannot_keep}
423+
for tool, action in sorted(self.keys.by_the_call):
424+
said.setdefault(
425+
(tool, action),
426+
f"same-request-key: {tool}/{action} says `the-call`, the call's own place in "
427+
"the run, and this harness sends the server no request identity of its own: "
428+
"a call it tries again after a lost reply may be made twice. A host that "
429+
"keeps every call (`durability:`) sends one.",
430+
)
408431
return tuple(said[k] for k in sorted(said))
409432

410433

‎adapters/python/src/pact_adapters/harness.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2942,7 +2942,11 @@ def _charge_summary(cost: "tuple[int, float] | None") -> None:
29422942
# its own ending.
29432943
meter.steps = limit
29442944
return await _ran_out(
2945-
Reached(step_ceiling(limit), float(limit), spec.limits.when_it_runs_out),
2945+
Reached(
2946+
step_ceiling(limit, default=not spec.steps_written and not spec.unbounded),
2947+
float(limit),
2948+
spec.limits.when_it_runs_out,
2949+
),
29462950
spec=spec, transport=transport, result=result, history=history,
29472951
already=already, bus=bus, meter=meter, now=now, clock=clock, at=limit,
29482952
phase=phase_name, visits=visits, asking=asking, once=once, chain=chain, given=given,

‎adapters/python/src/pact_adapters/interceptors.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1653,8 +1653,13 @@ def _scanner(patterns: tuple[tuple[re.Pattern[str], str], ...]) -> re.Pattern[st
16531653

16541654

16551655
def _thing_of(pattern: str) -> str:
1656-
"""Which recogniser a pattern is, by name (`card number`), or `""`."""
1657-
return next((thing for thing, p in RECOGNISES.items() if p == pattern), "")
1656+
"""Which recogniser a pattern is, by name (`card number`, or a workspace's own detector,
1657+
`order reference`, when its `detectors:` are in scope), or `""`. A placeholder is named for it,
1658+
so whoever reads `⟦order-reference-1⟧` knows what kind of value stands there."""
1659+
from .detectors import DECLARED
1660+
1661+
known = {**RECOGNISES, **DECLARED.get()}
1662+
return next((thing for thing, p in known.items() if p == pattern), "")
16581663

16591664

16601665
def _substitute(text: str, patterns: Sequence[tuple[re.Pattern[str], str]]) -> str:

‎adapters/python/src/pact_adapters/limits.py‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ class Ceiling:
8181
unit: str
8282
#: The short tag reported as `RunResult.halted`.
8383
halted: str
84+
#: Nobody wrote this figure: it is PACT's own (`ir.DEFAULT_STEPS`, law 2), so
85+
#: the sentence must not tell a person it is "the limit you set".
86+
default: bool = False
8487

8588

8689
@dataclass(frozen=True)
@@ -114,6 +117,20 @@ def sentence(self) -> str:
114117
happened — a message saying only "limit exceeded" leaves the reader with
115118
nowhere to go, which is the failure D13 rules out."""
116119
what = self.what
120+
if self.ceiling.default:
121+
whose = (
122+
f"PACT's default limit, {what}, which applies when no "
123+
f"`{self.ceiling.field}:` is written (write one under `limits:` to set "
124+
f"your own)"
125+
)
126+
if self.action is Action.ASK:
127+
return f"Waiting for a person: this run reached {whose}."
128+
if self.action is Action.ANSWER:
129+
return (
130+
f"This run reached {whose}, so it answered with what it had rather "
131+
f"than finishing the work."
132+
)
133+
return f"Stopped before finishing: this run reached {whose}."
117134
if self.action is Action.ASK:
118135
return f"Waiting for a person: this run reached the limit you set with {what}."
119136
if self.action is Action.ANSWER:
@@ -582,7 +599,9 @@ def across_runs(self) -> tuple[str, ...]:
582599
meter. Reported, never dropped (T7)."""
583600
return ("counted-per",) if self.counted_per not in ("", "run") else ()
584601

585-
def ceilings(self, steps_at_most: int | None = None) -> tuple[Ceiling, ...]:
602+
def ceilings(
603+
self, steps_at_most: int | None = None, steps_default: bool = False
604+
) -> tuple[Ceiling, ...]:
586605
"""Every ceiling in force, in a FIXED order.
587606
588607
The order is the report's tie-break: when a step and a cost ceiling are
@@ -592,7 +611,7 @@ def ceilings(self, steps_at_most: int | None = None) -> tuple[Ceiling, ...]:
592611
"""
593612
rows: list[Ceiling] = []
594613
if steps_at_most is not None:
595-
rows.append(step_ceiling(steps_at_most))
614+
rows.append(step_ceiling(steps_at_most, default=steps_default))
596615
if self.tool_calls_at_most is not None:
597616
rows.append(
598617
Ceiling(
@@ -619,10 +638,15 @@ def ceilings(self, steps_at_most: int | None = None) -> tuple[Ceiling, ...]:
619638
return tuple(rows)
620639

621640
def reached(
622-
self, meter: Meter, now: float, steps_at_most: int | None = None
641+
self,
642+
meter: Meter,
643+
now: float,
644+
steps_at_most: int | None = None,
645+
steps_default: bool = False,
623646
) -> Reached | None:
624-
"""The first ceiling this run has reached, or `None`."""
625-
for c in self.ceilings(steps_at_most):
647+
"""The first ceiling this run has reached, or `None`. `steps_default` says
648+
the step ceiling is PACT's default rather than the author's."""
649+
for c in self.ceilings(steps_at_most, steps_default):
626650
at = meter.reading(c.reads, now)
627651
if at >= c.limit:
628652
return Reached(c, at, self.when_it_runs_out)
@@ -772,15 +796,15 @@ def _nothing_can_reach(cap: float) -> bool:
772796
return math.isnan(cap) or cap == math.inf
773797

774798

775-
def step_ceiling(limit: int) -> Ceiling:
799+
def step_ceiling(limit: int, default: bool = False) -> Ceiling:
776800
"""The step ceiling as a row of the algebra.
777801
778802
It is enforced by the loop's own bound rather than by `reached`, but it ends
779803
a run through the same door as every other ceiling — otherwise `steps-at-most`
780804
and `cost-per-request-under` drift into behaving differently, which is what
781805
happens whenever each limit is given its own ending.
782806
"""
783-
return Ceiling("steps-at-most", "steps", float(limit), "steps", "step-limit")
807+
return Ceiling("steps-at-most", "steps", float(limit), "steps", "step-limit", default)
784808

785809

786810
#: Every way `RunResult.halted` can say "a ceiling ended this". A caller that

‎adapters/python/src/pact_adapters/pydantic_ai_registry.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ fields:
172172
outcome: loss
173173
rows: [96-A6]
174174
agent.turns:
175-
construct: "nothing yet: the turns are carried on the document and not yet passed as message_history to Agent.run"
176-
outcome: loss
175+
construct: "message_history on Agent.run: pydantic_ai_turns.messages_of lowers them (SystemPromptPart, UserPromptPart, ModelResponse, ToolCallPart, ToolReturnPart, docs/96 §D)"
176+
outcome: mapped
177177
rows: [96-D]
178178
agent.interceptors:
179179
construct: "an AbstractCapability whose before_model_request / before_tool_execute hooks run interceptors.Chain"
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""`turns:` as Pydantic AI messages (docs/96 §D): the one lowering every host on Pydantic AI uses.
2+
3+
An agent's `turns:` (a worked example placed before every live conversation), a variant's (the
4+
few-shot a smaller model needs) and an eval case's (the conversation so far) are one `turn` group,
5+
and `pact check` has already held them to their rules (`turns.rs`): `system` and `developer` only
6+
before anyone speaks, every `calls:` answered by the very next `tool` turn, a call to an action
7+
the agent may use. This module lowers them, by §D's table:
8+
9+
| PACT | Pydantic AI |
10+
|---|---|
11+
| `system`, `developer` | `SystemPromptPart` (both: the provider places them) |
12+
| `user` | `UserPromptPart`, with what `attaches:` names after the words |
13+
| `agent` | `ModelResponse([TextPart])` |
14+
| `agent` with `calls:` | `ModelResponse([ToolCallPart])`, `with:` as its arguments |
15+
| `tool` | `ToolReturnPart`, `answer:` as its content, under the call's own id |
16+
17+
Consecutive parts of one side share one message, as a provider sends them. What `says:` is filled
18+
with, how an attachment becomes content, and what a `<tool>/<action>` is called where the model
19+
sees it are the host's (`fill`, `attached`, `tool_name`): an agent's turns may carry holes, a case's
20+
never do, and each host names its tools its own way.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from collections.abc import Callable, Iterable, Mapping
26+
from typing import Any
27+
28+
from pydantic_ai.messages import (
29+
ModelMessage,
30+
ModelRequest,
31+
ModelRequestPart,
32+
ModelResponse,
33+
ModelResponsePart,
34+
SystemPromptPart,
35+
TextPart,
36+
ToolCallPart,
37+
ToolReturnPart,
38+
UserPromptPart,
39+
)
40+
41+
__all__ = ["messages_of"]
42+
43+
#: The speakers whose words are standing instructions rather than a turn of the conversation.
44+
STANDING = ("system", "developer")
45+
46+
47+
def _same(text: str) -> str:
48+
return text
49+
50+
51+
def messages_of(
52+
turns: Iterable[Mapping[str, Any]],
53+
*,
54+
fill: Callable[[str], str] | None = None,
55+
attached: Callable[[str], Any] | None = None,
56+
tool_name: Callable[[str], str] | None = None,
57+
call_ids: str = "turn",
58+
) -> list[ModelMessage]:
59+
"""The turns as Pydantic AI messages, oldest first. `fill` fills a turn's words (an agent's,
60+
with its holes; a case's are used as written); `attached(name)` is one `UserContent` for an
61+
`attaches:` entry; `tool_name("<tool>/<action>")` is the name the model calls it by. Each call
62+
is given the id `<call_ids>-<n>`, and the `tool` turn after it answers under the same id."""
63+
fill, tool_name = fill or _same, tool_name or _same
64+
out: list[ModelMessage] = []
65+
request: list[ModelRequestPart] = []
66+
response: list[ModelResponsePart] = []
67+
pending: tuple[str, str] | None = None # the call the next `tool` turn answers: (name, id)
68+
n = 0
69+
70+
def close_request() -> None:
71+
if request:
72+
out.append(ModelRequest(parts=list(request)))
73+
request.clear()
74+
75+
def close_response() -> None:
76+
if response:
77+
out.append(ModelResponse(parts=list(response)))
78+
response.clear()
79+
80+
for turn in turns:
81+
speaker = str(turn.get("from") or "").strip()
82+
says = turn.get("says")
83+
if speaker in STANDING:
84+
close_response()
85+
request.append(SystemPromptPart(fill(str(says or ""))))
86+
elif speaker == "user":
87+
close_response()
88+
content: list[Any] = [fill(str(says))] if says else []
89+
for name in turn.get("attaches") or ():
90+
if attached is not None and (one := attached(str(name))) is not None:
91+
content.append(one)
92+
request.append(UserPromptPart(content if len(content) != 1 or not isinstance(content[0], str) else content[0]))
93+
elif speaker == "agent":
94+
close_request()
95+
if says:
96+
response.append(TextPart(fill(str(says))))
97+
if turn.get("calls"):
98+
n += 1
99+
name, call_id = tool_name(str(turn["calls"]).strip()), f"{call_ids}-{n}"
100+
response.append(ToolCallPart(name, dict(turn.get("with") or {}), tool_call_id=call_id))
101+
pending = (name, call_id)
102+
elif speaker == "tool":
103+
close_response()
104+
name, call_id = pending if pending is not None else ("tool", f"{call_ids}-{n}")
105+
request.append(ToolReturnPart(name, turn.get("answer"), tool_call_id=call_id))
106+
pending = None
107+
close_request()
108+
close_response()
109+
return out

‎adapters/python/tests/test_a_hidden_value_comes_back_only_where_it_is_needed.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,16 @@ def test_a_tool_answer_is_not_kept_where_never_from_refuses_it() -> None:
120120

121121
_keep_what_it_answered(spec, ToolCall("lookup", {"action": "get"}), "always approve this account")
122122
assert "said" not in spec.facts.held, spec.facts.held
123+
124+
125+
def test_a_workspace_detectors_placeholder_is_named_for_it() -> None:
126+
"""`⟦order-reference-1⟧`, not an anonymous `⟦hidden-1⟧`: a reviewer reads what kind it was."""
127+
from pact_adapters.detectors import DECLARED
128+
from pact_adapters.interceptors import _thing_of
129+
130+
held = DECLARED.set({"order reference": "(?:ORD-[0-9]{8})"})
131+
try:
132+
assert _thing_of("(?:ORD-[0-9]{8})") == "order reference"
133+
finally:
134+
DECLARED.reset(held)
135+
assert _thing_of("(?:ORD-[0-9]{8})") == "", "with no detectors in scope, nothing to name it by"

‎adapters/python/tests/test_every_host_receives_a_step_ceiling.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,20 @@ def test_unbounded_no_is_the_default() -> None:
6464
spec = _spec({"unbounded": "no"})
6565
assert not spec.unbounded
6666
assert usage_limits_for(spec).request_limit == DEFAULT_STEPS
67+
68+
69+
def test_a_default_ceiling_is_never_called_the_limit_you_set() -> None:
70+
"""A person told a run stopped at "the limit you set" goes looking for a line
71+
nobody wrote. The default ceiling says it is PACT's, and how to set one."""
72+
from pact_adapters.limits import Action, Limits, Meter, Reached, step_ceiling
73+
74+
said = Reached(step_ceiling(DEFAULT_STEPS, default=True), float(DEFAULT_STEPS), Action.STOP).sentence()
75+
assert "you set" not in said
76+
assert f"PACT's default limit, `steps-at-most` ({DEFAULT_STEPS} of {DEFAULT_STEPS} steps)" in said
77+
assert "write one under `limits:`" in said
78+
written = Reached(step_ceiling(3), 3.0, Action.STOP).sentence()
79+
assert "the limit you set with `steps-at-most` (3 of 3 steps)" in written
80+
meter = Meter(started=0.0)
81+
meter.steps = DEFAULT_STEPS
82+
reached = Limits().reached(meter, 0.0, DEFAULT_STEPS, steps_default=True)
83+
assert reached is not None and reached.ceiling.default

0 commit comments

Comments
 (0)