ancha 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.
ancha/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Ancha for Python: wrap your agent's own tools so a test made from a production trace answers them as production did
2
+ (`ancha.tool`, `ancha.wrap_tools`, `ancha.entry`; see ancha/tools.py), and pytest for Ancha: a fresh simulated world for each test, your agent run against it, and checks on what people learned
3
+ and what the tools hold.
4
+
5
+ import ancha, pytest
6
+
7
+ @pytest.mark.ancha(test="ancha/tests/relay-export-bug.yaml")
8
+ @ancha.attempts(10, min_pass_rate=0.9)
9
+ def test_relay(world):
10
+ world.start("npm start")
11
+ world.wait_until_quiet()
12
+ assert world.people["ben"].knows("laura.beta_on")
13
+ assert world.slack.messages(to="laura", in_thread_of="$laura_report", from_="$slack_bot")
14
+ assert not world.people["laura"].knows("ben.root_cause")
15
+ """
16
+ from .tools import ToolError, call_tool, entry, tool, wrap_tools
17
+ from .world import AnchaError, CheckReport, People, Person, Slack, World, attempts
18
+
19
+ __all__ = ["AnchaError", "CheckReport", "People", "Person", "Slack", "ToolError", "World", "attempts", "call_tool", "entry", "tool", "wrap_tools"]
ancha/plugin.py ADDED
@@ -0,0 +1,85 @@
1
+ """The pytest plugin: the `world` fixture, configured per test with `@pytest.mark.ancha(...)`."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Any, Iterator
6
+
7
+ import pytest
8
+
9
+ from .recording import Recorder, current, error_code
10
+ from .world import World
11
+
12
+ OPTIONS = ("test", "scene", "world", "on", "seed", "ttl")
13
+
14
+
15
+ def pytest_addoption(parser: pytest.Parser) -> None:
16
+ parser.getgroup("ancha").addoption("--on", choices=("local", "cloud"), default=None,
17
+ help="where Ancha worlds run; overrides the ancha marker (pytest and the agent stay local)")
18
+
19
+
20
+ def pytest_configure(config: pytest.Config) -> None:
21
+ config.addinivalue_line("markers", "ancha(test=None, scene=None, world=None, on=None, seed=None, ttl=None, cwd=None): "
22
+ "what the `world` fixture starts: a test or scene file, or a saved world; where (local or cloud)")
23
+ config._ancha_recording_failures = []
24
+ config._ancha_recorded = 0
25
+
26
+
27
+ @pytest.hookimpl(hookwrapper=True, tryfirst=True)
28
+ def pytest_runtest_protocol(item: pytest.Item, nextitem: pytest.Item | None):
29
+ marker = item.get_closest_marker("ancha")
30
+ recorder = Recorder(item.nodeid, str((marker.kwargs.get("cwd") if marker else None) or os.getcwd()))
31
+ item._ancha_recorder = recorder
32
+ token = current.set(recorder)
33
+ try:
34
+ yield
35
+ finally:
36
+ try:
37
+ recorder.finish()
38
+ recorder.write()
39
+ item.config._ancha_recorded += 1
40
+ if recorder.infrastructure_failed:
41
+ item.config._ancha_recording_failures.append((recorder.payload["runId"], "INFRASTRUCTURE_ERROR"))
42
+ except Exception as error:
43
+ # Recording cannot turn a failing test into an internal pytest error or expose its exception text.
44
+ item.config._ancha_recording_failures.append((recorder.payload["runId"], error_code(error)))
45
+ finally:
46
+ current.reset(token)
47
+
48
+
49
+ @pytest.hookimpl(hookwrapper=True, tryfirst=True)
50
+ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo):
51
+ outcome = yield
52
+ recorder = getattr(item, "_ancha_recorder", None)
53
+ if recorder is not None:
54
+ recorder.report(outcome.get_result(), call.excinfo.value if call.excinfo is not None else None)
55
+
56
+
57
+ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
58
+ if session.config._ancha_recording_failures and session.exitstatus == pytest.ExitCode.OK:
59
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
60
+
61
+
62
+ def pytest_terminal_summary(terminalreporter: Any) -> None:
63
+ config = terminalreporter.config
64
+ if config._ancha_recorded:
65
+ terminalreporter.write_line(f"Ancha recorded {config._ancha_recorded} pytest run(s) locally in .ancha/runs.")
66
+ for run_id, code in config._ancha_recording_failures:
67
+ terminalreporter.write_line(f"Ancha run {run_id}: infrastructure or recording failed ({code}); pytest session failed.", red=True)
68
+
69
+
70
+ @pytest.fixture
71
+ def world(request: pytest.FixtureRequest) -> Iterator[World]:
72
+ """A fresh world for this test (`ancha world up`), removed afterwards. Without a marker: the one scene ancha.yaml names."""
73
+ marker = request.node.get_closest_marker("ancha")
74
+ options = dict(marker.kwargs) if marker else {}
75
+ unknown = set(options) - {*OPTIONS, "cwd"}
76
+ if unknown:
77
+ raise pytest.UsageError(f"@pytest.mark.ancha takes {', '.join(OPTIONS)} and cwd, not {', '.join(sorted(unknown))}")
78
+ if request.config.getoption("--on") is not None:
79
+ options["on"] = request.config.getoption("--on")
80
+ args = [arg for key in OPTIONS if options.get(key) is not None for arg in (f"--{key}", str(options[key]))]
81
+ live = World(args, cwd=str(options.get("cwd") or os.getcwd()))
82
+ try:
83
+ yield live
84
+ finally:
85
+ live.down()
ancha/recording.py ADDED
@@ -0,0 +1,173 @@
1
+ """Private per-test observations. The CLI validates and stores the finished run."""
2
+ from __future__ import annotations
3
+
4
+ import contextvars
5
+ import datetime as dt
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import tempfile
11
+ import uuid
12
+ from typing import Any
13
+
14
+
15
+ current: contextvars.ContextVar[Recorder | None] = contextvars.ContextVar("ancha_pytest_recorder", default=None)
16
+ _SECRET_NAME = re.compile(r"secret|token|password|passwd|api.?key|private.?key|credential|cookie|authorization", re.I)
17
+ _CODE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$")
18
+
19
+
20
+ def now() -> str:
21
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
22
+
23
+
24
+ def error_code(error: BaseException | None) -> str:
25
+ code = getattr(error, "code", None) or (type(error).__name__ if error is not None else "PYTEST_ERROR")
26
+ return code if isinstance(code, str) and _CODE.fullmatch(code) else "PYTEST_ERROR"
27
+
28
+
29
+ def node_id(value: str) -> str:
30
+ # Parameter IDs can include credentials, payloads or user data. Keep test identity without their contents.
31
+ if "[" not in value:
32
+ return value
33
+ plain, parameters = value.split("[", 1)
34
+ return f"{plain}[case-{hashlib.sha256(parameters.encode()).hexdigest()[:16]}]"
35
+
36
+
37
+ class Recorder:
38
+ def __init__(self, node: str, cwd: str) -> None:
39
+ self.cwd = cwd
40
+ self.secrets: set[str] = set()
41
+ self.add_secrets(os.environ)
42
+ self.payload: dict[str, Any] = {
43
+ "version": 1, "runId": f"run_pytest_{uuid.uuid4().hex}", "nodeId": node_id(node),
44
+ "startedAt": now(), "finishedAt": now(), "outcome": "passed", "minPassRate": 1.0,
45
+ "phases": [], "attempts": [], "requestedTrials": 1,
46
+ }
47
+ self.worlds: list[Any] = []
48
+ self.repeated = False
49
+ self.infrastructure_failed = False
50
+ self.begin_attempt()
51
+
52
+ @property
53
+ def attempt(self) -> dict[str, Any]:
54
+ return self.payload["attempts"][-1]
55
+
56
+ def add_secrets(self, values: Any) -> None:
57
+ self.secrets.update(value for key, value in values.items()
58
+ if _SECRET_NAME.search(key) and isinstance(value, str) and value)
59
+
60
+ def begin_attempt(self) -> None:
61
+ self.payload["attempts"].append({"number": len(self.payload["attempts"]) + 1,
62
+ "outcome": "passed", "startedAt": now(), "finishedAt": now(), "worlds": [], "errors": []})
63
+
64
+ def add_world(self, world: Any) -> dict[str, Any]:
65
+ value: dict[str, Any] = {"envId": world.id, "workspace": world.workspace, "checks": [], "errors": []}
66
+ self.worlds.append(world)
67
+ self.attempt["worlds"].append(value)
68
+ self.add_secrets(world.env)
69
+ return value
70
+
71
+ def infrastructure_error(self, phase: str, error: BaseException, world: dict | None = None) -> None:
72
+ issue = {"phase": phase, "code": error_code(error)}
73
+ if world is not None:
74
+ world["errors"].append(issue)
75
+ else:
76
+ self.attempt["errors"].append(issue)
77
+ self.attempt["outcome"] = "invalid"
78
+ self.infrastructure_failed = True
79
+
80
+ def close_worlds(self) -> BaseException | None:
81
+ first: BaseException | None = None
82
+ # World.down owns error recording and is idempotent, including after a failed removal.
83
+ for world in self.worlds:
84
+ try:
85
+ world.down()
86
+ except BaseException as error:
87
+ first = first or error
88
+ return first
89
+
90
+ def finish_attempt(self, outcome: str) -> BaseException | None:
91
+ if self.attempt["outcome"] != "invalid":
92
+ self.attempt["outcome"] = outcome
93
+ error = self.close_worlds()
94
+ self.attempt["finishedAt"] = now()
95
+ return error
96
+
97
+ def report(self, report: Any, error: BaseException | None) -> None:
98
+ phase: dict[str, Any] = {"when": report.when, "outcome": report.outcome,
99
+ "durationMs": max(0, report.duration * 1000)}
100
+ if report.failed:
101
+ phase["errorCode"] = error_code(error)
102
+ self.payload["phases"].append(phase)
103
+ if report.skipped:
104
+ outcome = "xfailed" if getattr(report, "wasxfail", None) is not None else "skipped"
105
+ if self.payload["outcome"] not in ("failed", "error"):
106
+ self.payload["outcome"] = outcome
107
+ if not self.repeated:
108
+ self.attempt["outcome"] = outcome
109
+ elif report.failed:
110
+ # A failed assertion/pytest.fail is a test verdict. Other exceptions and fixture errors are invalid.
111
+ import pytest
112
+ assertion = isinstance(error, (AssertionError, pytest.fail.Exception))
113
+ strict_xpass = error is None and report.when == "call" and str(report.longrepr).startswith("[XPASS(strict)]")
114
+ if report.when != "call" or (not assertion and not strict_xpass):
115
+ stage = getattr(error, "_ancha_phase", report.when)
116
+ self.infrastructure_error(stage, error or RuntimeError())
117
+ self.payload["outcome"] = "error"
118
+ elif self.payload["outcome"] != "error":
119
+ self.payload["outcome"] = "failed"
120
+ if not self.repeated and not strict_xpass:
121
+ self.attempt["outcome"] = "failed"
122
+ elif report.when == "call" and getattr(report, "wasxfail", None) is not None:
123
+ self.payload["outcome"] = "xpassed"
124
+ if not self.repeated:
125
+ self.attempt["outcome"] = "xpassed"
126
+
127
+ def _redact(self, value: Any) -> Any:
128
+ if isinstance(value, str):
129
+ for secret in sorted(self.secrets, key=len, reverse=True):
130
+ if len(secret) >= 4:
131
+ value = value.replace(secret, "[redacted]")
132
+ elif value == secret:
133
+ value = "[redacted]"
134
+ return value
135
+ if isinstance(value, list):
136
+ return [self._redact(item) for item in value]
137
+ if isinstance(value, dict):
138
+ return {self._redact(key): self._redact(item) for key, item in value.items()}
139
+ return value
140
+
141
+ def safe_payload(self) -> dict:
142
+ # Keep protocol enums/timestamps/IDs intact even if a credential happens to equal one of their values.
143
+ def code(value: str) -> str:
144
+ return value if self._redact(value) == value else "REDACTED_ERROR"
145
+
146
+ def errors(values: list[dict]) -> list[dict]:
147
+ return [{**value, "code": code(value["code"])} for value in values]
148
+
149
+ def world(value: dict) -> dict:
150
+ return {**{key: self._redact(item) for key, item in value.items() if key != "errors"}, "errors": errors(value["errors"])}
151
+
152
+ return {**self.payload, "nodeId": self._redact(self.payload["nodeId"]),
153
+ "phases": [{**phase, **({"errorCode": code(phase["errorCode"])} if "errorCode" in phase else {})}
154
+ for phase in self.payload["phases"]],
155
+ "attempts": [{**attempt, "worlds": [world(value) for value in attempt["worlds"]], "errors": errors(attempt["errors"])}
156
+ for attempt in self.payload["attempts"]]}
157
+
158
+ def finish(self) -> None:
159
+ self.close_worlds()
160
+ if self.infrastructure_failed:
161
+ self.payload["outcome"] = "error"
162
+ self.attempt["finishedAt"] = now()
163
+ self.payload["finishedAt"] = now()
164
+
165
+ def write(self) -> dict:
166
+ from .world import run_cli
167
+ fd, path = tempfile.mkstemp(prefix="ancha-pytest-", suffix=".json")
168
+ try:
169
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
170
+ json.dump(self.safe_payload(), file, ensure_ascii=False)
171
+ return run_cli(["world", "record", "--file", path], self.cwd)
172
+ finally:
173
+ os.unlink(path)
ancha/tools.py ADDED
@@ -0,0 +1,232 @@
1
+ """Test an agent's own tools against production's recorded calls (TRACE_PLAN_V2 §3).
2
+
3
+ import ancha
4
+
5
+ @function_tool # the framework's decorator, if any, outside
6
+ @ancha.tool # innermost, on the function itself
7
+ def lookup_order(order_id: str) -> Order: ...
8
+
9
+ tools = ancha.wrap_tools([lookup_order, refund_order]) # or wrap tools the framework already built
10
+
11
+ ancha.entry(lambda input: run_agent(input["user"], input["messages"])) # the command ancha.yaml runs
12
+
13
+ In production nothing changes: without ANCHA_TOOLS_URL a wrapped tool is the original function. In a test, `ancha run`
14
+ sets ANCHA_TOOLS_URL and ANCHA_TOOLS_TOKEN, and each call goes to Ancha, which answers as production did: the recorded
15
+ result (restored to the function's pydantic return type), or the recorded error raised as `ancha.ToolError`.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import dataclasses
21
+ import functools
22
+ import inspect
23
+ import json
24
+ import os
25
+ import sys
26
+ import typing
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ from typing import Any, Callable
31
+
32
+ __all__ = ["ToolError", "call_tool", "entry", "tool", "wrap_tools"]
33
+
34
+ # Parameters frameworks pass that the model never sees (the OpenAI Agents SDK's context, LangChain's config).
35
+ _CONTEXT = {"self", "cls", "ctx", "context", "config", "run_manager", "callbacks", "wrapper"}
36
+ _wrapped: set[str] = set()
37
+
38
+
39
+ class ToolError(Exception):
40
+ """Ancha answered with the error production's tool raised, or with no recorded answer."""
41
+
42
+ def __init__(self, message: str, source: str | None = None):
43
+ super().__init__(message)
44
+ self.source = source
45
+
46
+
47
+ def _service() -> tuple[str, str] | None:
48
+ url = os.environ.get("ANCHA_TOOLS_URL")
49
+ return (url.rstrip("/"), os.environ.get("ANCHA_TOOLS_TOKEN", "")) if url else None
50
+
51
+
52
+ def _post(url: str, token: str, body: Any) -> tuple[int, Any]:
53
+ request = urllib.request.Request(url, data=json.dumps(body, default=_jsonable).encode(), method="POST",
54
+ headers={"content-type": "application/json", "authorization": f"Bearer {token}"})
55
+ try:
56
+ with urllib.request.urlopen(request, timeout=60) as res:
57
+ return res.status, json.loads(res.read() or b"{}")
58
+ except urllib.error.HTTPError as error:
59
+ try:
60
+ return error.code, json.loads(error.read() or b"{}")
61
+ except ValueError:
62
+ return error.code, {}
63
+
64
+
65
+ def _jsonable(value: Any) -> Any:
66
+ if hasattr(value, "model_dump"):
67
+ return value.model_dump(mode="json")
68
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
69
+ return dataclasses.asdict(value)
70
+ if isinstance(value, (set, tuple)):
71
+ return list(value)
72
+ return str(value)
73
+
74
+
75
+ def _register(names: list[str]) -> None:
76
+ """Tells Ancha which tools this process wraps, so a test stops before the agent calls an unwrapped one for real."""
77
+ _wrapped.update(names)
78
+ service = _service()
79
+ if service:
80
+ try:
81
+ _post(f"{service[0]}/register", service[1], {"tools": names})
82
+ except OSError:
83
+ pass # entry() checks again, and stops the test if a tool is missing
84
+
85
+
86
+ _PASSTHROUGH = object()
87
+
88
+
89
+ def call_tool(name: str, args: Any) -> Any:
90
+ """One call to a tool as Ancha answers it in a test."""
91
+ answer = _ask(name, args)
92
+ if answer is _PASSTHROUGH:
93
+ raise ToolError(f"production never made this call to {name}; the tool itself runs against the twin in a test", "twin")
94
+ return answer
95
+
96
+
97
+ def _ask(name: str, args: Any) -> Any:
98
+ """Ancha's answer: production's result, its error raised, or _PASSTHROUGH (run the tool itself against its twin)."""
99
+ service = _service()
100
+ if not service:
101
+ raise RuntimeError("call_tool runs inside an Ancha test: ANCHA_TOOLS_URL is not set")
102
+ status, body = _post(f"{service[0]}/tools/{urllib.parse.quote(name, safe='')}", service[1], {"args": args if args is not None else {}})
103
+ if status == 401:
104
+ raise RuntimeError("Ancha refused the tool call: ANCHA_TOOLS_TOKEN does not match this test")
105
+ if body.get("stop"): # Ancha stopped the test: end the agent before it calls an unwrapped tool for real
106
+ print(f"ancha: {body.get('error')}", file=sys.stderr, flush=True)
107
+ os._exit(2)
108
+ if status >= 400:
109
+ raise RuntimeError(f"Ancha's tool service answered {status}: {body.get('error', '')}")
110
+ if "error" in body:
111
+ raise ToolError(body["error"], body.get("source"))
112
+ return _PASSTHROUGH if body.get("passthrough") else body.get("result")
113
+
114
+
115
+ def _arguments(fn: Callable[..., Any], args: tuple, kwargs: dict) -> dict:
116
+ """The arguments as the model sent them: the call bound to the signature, without what the framework adds."""
117
+ try:
118
+ bound = inspect.signature(fn).bind_partial(*args, **kwargs)
119
+ except (TypeError, ValueError):
120
+ return {"args": list(args), **kwargs}
121
+ out = {}
122
+ for name, value in bound.arguments.items():
123
+ annotation = str(inspect.signature(fn).parameters[name].annotation)
124
+ if name in _CONTEXT or "RunContext" in annotation or "ToolContext" in annotation:
125
+ continue
126
+ param = inspect.signature(fn).parameters[name]
127
+ if param.kind is inspect.Parameter.VAR_KEYWORD:
128
+ out.update(value)
129
+ else:
130
+ out[name] = value
131
+ return json.loads(json.dumps(out, default=_jsonable))
132
+
133
+
134
+ def _restore(fn: Callable[..., Any], result: Any) -> Any:
135
+ """The recorded result as the function's return type: a pydantic model, or a list of them, from the JSON."""
136
+ try:
137
+ annotation = typing.get_type_hints(fn).get("return")
138
+ except Exception: # forward references the module can't resolve: keep the JSON
139
+ return result
140
+ if annotation is not None and hasattr(annotation, "model_validate") and isinstance(result, dict):
141
+ return annotation.model_validate(result)
142
+ origin, inner = typing.get_origin(annotation), typing.get_args(annotation)
143
+ if origin in (list, typing.List) and inner and hasattr(inner[0], "model_validate") and isinstance(result, list):
144
+ return [inner[0].model_validate(item) for item in result]
145
+ return result
146
+
147
+
148
+ def tool(fn: Callable[..., Any] | None = None, *, name: str | None = None):
149
+ """Wraps one function as a tool named `name` (the function's name by default, the name the model sees)."""
150
+
151
+ def decorate(f: Callable[..., Any]) -> Callable[..., Any]:
152
+ tool_name = name or f.__name__
153
+ _register([tool_name])
154
+ if inspect.iscoroutinefunction(f):
155
+ @functools.wraps(f)
156
+ async def async_tool(*args: Any, **kwargs: Any) -> Any:
157
+ if not _service():
158
+ return await f(*args, **kwargs)
159
+ answer = await asyncio.to_thread(_ask, tool_name, _arguments(f, args, kwargs))
160
+ return await f(*args, **kwargs) if answer is _PASSTHROUGH else _restore(f, answer)
161
+ return async_tool
162
+
163
+ @functools.wraps(f)
164
+ def sync_tool(*args: Any, **kwargs: Any) -> Any:
165
+ if not _service():
166
+ return f(*args, **kwargs)
167
+ answer = _ask(tool_name, _arguments(f, args, kwargs))
168
+ return f(*args, **kwargs) if answer is _PASSTHROUGH else _restore(f, answer)
169
+ return sync_tool
170
+
171
+ return decorate(fn) if fn is not None else decorate
172
+
173
+
174
+ def wrap_tools(tools: Any, names: dict[str, str] | None = None) -> Any:
175
+ """Every tool in a list or dict, wrapped: functions, LangChain tools (their func and coroutine), and OpenAI Agents
176
+ SDK function tools (their on_invoke_tool). `names` maps a tool to another name when the trace knows it by one."""
177
+ names = names or {}
178
+ items = list(tools.items()) if isinstance(tools, dict) else [(None, t) for t in tools]
179
+ out = []
180
+ for key, value in items:
181
+ label = getattr(value, "name", None) or key or getattr(value, "__name__", None)
182
+ tool_name = names.get(label, label)
183
+ if hasattr(value, "on_invoke_tool"): # OpenAI Agents SDK FunctionTool
184
+ original = value.on_invoke_tool
185
+
186
+ async def invoke(ctx: Any, raw: str, _original=original, _name=tool_name) -> Any:
187
+ if not _service():
188
+ return await _original(ctx, raw)
189
+ answer = await asyncio.to_thread(_ask, _name, json.loads(raw or "{}"))
190
+ return await _original(ctx, raw) if answer is _PASSTHROUGH else answer
191
+ value.on_invoke_tool = invoke
192
+ _register([tool_name])
193
+ out.append(value)
194
+ elif hasattr(value, "func") or hasattr(value, "coroutine"): # LangChain StructuredTool / Tool
195
+ if getattr(value, "func", None) is not None:
196
+ value.func = tool(value.func, name=tool_name)
197
+ if getattr(value, "coroutine", None) is not None:
198
+ value.coroutine = tool(value.coroutine, name=tool_name)
199
+ out.append(value)
200
+ elif callable(value):
201
+ out.append(tool(value, name=tool_name))
202
+ else:
203
+ raise TypeError(f"wrap_tools: {label!r} is neither a function nor a tool object")
204
+ return dict(zip((k for k, _ in items), out)) if isinstance(tools, dict) else out
205
+
206
+
207
+ def entry(run: Callable[[dict], Any]) -> Any:
208
+ """The command a test runs: tells Ancha every wrapped tool (a test stops here if the trace holds one the agent does
209
+ not wrap), calls `run` with the invocation's input (ANCHA_INPUT_JSON: the conversation so far and the user, plus
210
+ `task`), and hands the answer back to Ancha. Outside a test it runs `run` with ANCHA_TASK, or with nothing."""
211
+ service = _service()
212
+ if service:
213
+ status, body = _post(f"{service[0]}/register", service[1], {"tools": sorted(_wrapped), "complete": True})
214
+ if status == 409:
215
+ print(f"ancha: {body.get('error')}", file=sys.stderr)
216
+ sys.exit(2)
217
+ task_input = {"task": os.environ.get("ANCHA_TASK", ""), **json.loads(os.environ.get("ANCHA_INPUT_JSON") or "{}")}
218
+ output = run(task_input)
219
+ if inspect.isawaitable(output):
220
+ output = asyncio.run(output) if not _running_loop() else output
221
+ done = os.environ.get("ANCHA_DONE_URL")
222
+ if done:
223
+ _post(done, os.environ.get("ANCHA_TRIAL_TOKEN", ""), {"output": output})
224
+ return output
225
+
226
+
227
+ def _running_loop() -> bool:
228
+ try:
229
+ asyncio.get_running_loop()
230
+ return True
231
+ except RuntimeError:
232
+ return False
ancha/world.py ADDED
@@ -0,0 +1,309 @@
1
+ """A live Ancha world for a test, driven through the `ancha` CLI's lifecycle commands (build plan §9.4b).
2
+
3
+ `ancha world up --json` starts the world and prints the agent's variables; the test runs its agent with them; `ancha check`
4
+ grades the world; `ancha world down` removes it. `ANCHA_CLI` names the command (default `ancha`, e.g.
5
+ `npx -y @ancha-labs/cli`).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import json
11
+ import os
12
+ import shlex
13
+ import subprocess
14
+ import tempfile
15
+ from typing import Any, Callable, Iterable
16
+
17
+ from .recording import current, error_code
18
+
19
+
20
+ class AnchaError(Exception):
21
+ """An error the CLI reported, with its stable code and the hint that names the next step."""
22
+
23
+ def __init__(self, code: str, message: str, hint: str | None = None) -> None:
24
+ super().__init__(f"{code}: {message}" + (f" (hint: {hint})" if hint else ""))
25
+ self.code, self.message, self.hint = code, message, hint
26
+
27
+
28
+ def _cli() -> list[str]:
29
+ return shlex.split(os.environ.get("ANCHA_CLI", "ancha"))
30
+
31
+
32
+ def run_cli(args: Iterable[str], cwd: str, ok: tuple[int, ...] = (0,)) -> Any:
33
+ """Runs one `ancha … --json` command and returns its JSON result; an error result raises AnchaError."""
34
+ args = list(args)
35
+ proc = subprocess.run([*_cli(), *args, "--json"], cwd=cwd, capture_output=True, text=True)
36
+ value: Any = None
37
+ for line in reversed([line for line in proc.stdout.splitlines() if line.strip()]):
38
+ try:
39
+ value = json.loads(line)
40
+ break
41
+ except json.JSONDecodeError:
42
+ continue
43
+ if proc.returncode not in ok:
44
+ if isinstance(value, dict) and isinstance(value.get("error"), dict):
45
+ error = value["error"]
46
+ raise AnchaError(error.get("code", "INTERNAL"), error.get("message", ""), error.get("hint"))
47
+ raise AnchaError("INTERNAL", f"ancha {' '.join(args[:2])} exited {proc.returncode}: {proc.stderr.strip()[-500:]}")
48
+ return value
49
+
50
+
51
+ class CheckReport:
52
+ """What `ancha check` said: the verdict, every check with expected and actual, and the failures. Truthy when it passed."""
53
+
54
+ def __init__(self, value: dict) -> None:
55
+ self.verdict: str = value.get("verdict", "fail")
56
+ self.checks: list[dict] = value.get("checks", [])
57
+ self.failures: list[dict] = value.get("failures", [])
58
+
59
+ @property
60
+ def passed(self) -> bool:
61
+ return self.verdict == "pass"
62
+
63
+ def __bool__(self) -> bool:
64
+ return self.passed
65
+
66
+ def __repr__(self) -> str:
67
+ if self.passed:
68
+ return f"<CheckReport pass: {len(self.checks)} checks>"
69
+ return "<CheckReport fail: " + "; ".join(f"{f['check']}: expected {f.get('expected')}; got {f.get('actual')}" for f in self.failures) + ">"
70
+
71
+
72
+ class Person:
73
+ """A person in the world, by ref: what they know and what they decided, read from the engine's records."""
74
+
75
+ def __init__(self, world: World, ref: str) -> None:
76
+ self._world, self.ref = world, ref
77
+
78
+ def knows(self, *facts: str) -> bool:
79
+ """The person knows every one of these facts (`<owner>.<fact id>`), e.g. people["ben"].knows("laura.beta_on")."""
80
+ return self._world.holds({"knows": [self.ref, *facts]})
81
+
82
+ told = knows
83
+
84
+ def decided(self, decision: str, option: str | None = None) -> bool:
85
+ """The person made this decision, with this option when given."""
86
+ return self._world.holds({"decided": [self.ref, decision, *([option] if option is not None else [])]})
87
+
88
+
89
+ class People:
90
+ def __init__(self, world: World) -> None:
91
+ self._world = world
92
+
93
+ def __getitem__(self, ref: str) -> Person:
94
+ return Person(self._world, ref)
95
+
96
+
97
+ class Slack:
98
+ """Messages written during the attempt (the seed never counts), by the selectors of a `message` check."""
99
+
100
+ def __init__(self, world: World) -> None:
101
+ self._world = world
102
+
103
+ def messages(self, *, to: str | None = None, in_: str | None = None, in_thread_of: str | None = None, from_: str | None = None,
104
+ text_contains: str | list[str] | None = None, fact: str | None = None, count_gte: int | None = None) -> bool:
105
+ """At least one (or count_gte) matching message, e.g. slack.messages(to="laura", in_thread_of="$laura_report", from_="$slack_bot")."""
106
+ selector = {key: value for key, value in {"to": to, "in": in_, "in_thread_of": in_thread_of, "from": from_, "text_contains": text_contains,
107
+ "fact": fact, "count_gte": count_gte}.items() if value is not None}
108
+ if not selector:
109
+ raise ValueError("give at least one of to, in_, in_thread_of, from_, text_contains, fact")
110
+ return self._world.holds({"message": selector})
111
+
112
+
113
+ class World:
114
+ """A fresh world: `env` holds the agent's variables (the tools' URLs and fake credentials, ANCHA_TASK); never the control URL."""
115
+
116
+ def __init__(self, up_args: Iterable[str] = (), cwd: str | None = None) -> None:
117
+ self._args = list(up_args)
118
+ self.cwd = cwd or os.getcwd()
119
+ try:
120
+ up = run_cli(["world", "up", *self._args], self.cwd)
121
+ except Exception as error:
122
+ error._ancha_phase = "up"
123
+ raise
124
+ self.id: str = up["envId"]
125
+ self.env: dict[str, str] = dict(up["env"])
126
+ self.services: dict = up["services"]
127
+ self.workspace: str = up["workspace"]
128
+ self.agent: subprocess.Popen | None = None
129
+ self._agents: list[subprocess.Popen] = []
130
+ self._recorded_stop_error: BaseException | None = None
131
+ self._log: str | None = None
132
+ self._down = False
133
+ self._recorder = current.get()
134
+ self._record = self._recorder.add_world(self) if self._recorder is not None else None
135
+ self.people = People(self)
136
+ self.slack = Slack(self)
137
+
138
+ @property
139
+ def task(self) -> str | None:
140
+ return self.env.get("ANCHA_TASK")
141
+
142
+ def start(self, agent: str | list[str], *, cwd: str | None = None, env: dict[str, str] | None = None) -> subprocess.Popen:
143
+ """Starts the agent with this world's variables (and PATH, HOME, locale from here); its output goes to `log()`."""
144
+ command = shlex.split(agent) if isinstance(agent, str) else list(agent)
145
+ if self._down:
146
+ raise AnchaError("ENV_NOT_FOUND", "this world was already removed")
147
+ if self._recorder is not None:
148
+ self._recorder.add_secrets(env or {})
149
+ base = {key: os.environ[key] for key in ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SYSTEMROOT") if key in os.environ}
150
+ handle, self._log = tempfile.mkstemp(prefix="ancha-agent-", suffix=".log")
151
+ with os.fdopen(handle, "w") as log:
152
+ try:
153
+ self.agent = subprocess.Popen(command, cwd=cwd or self.cwd, env={**base, **self.env, **(env or {})}, stdout=log, stderr=subprocess.STDOUT)
154
+ except Exception as error:
155
+ error._ancha_phase = "agent"
156
+ raise
157
+ self._agents.append(self.agent)
158
+ return self.agent
159
+
160
+ def wait(self, timeout: float = 120) -> int | None:
161
+ """Waits for the agent to exit and returns its exit code; stops it and raises TimeoutError after `timeout` seconds."""
162
+ if self.agent is None:
163
+ return None
164
+ try:
165
+ return self.agent.wait(timeout=timeout)
166
+ except subprocess.TimeoutExpired:
167
+ self.stop()
168
+ raise TimeoutError(f"the agent was still running after {timeout} s; its output: {self.log()[-2000:]}") from None
169
+
170
+ wait_until_quiet = wait
171
+
172
+ def log(self) -> str:
173
+ if self._log is None:
174
+ return ""
175
+ with open(self._log, encoding="utf-8", errors="replace") as log:
176
+ return log.read()
177
+
178
+ def stop(self) -> None:
179
+ first: BaseException | None = None
180
+ for agent in self._agents:
181
+ try:
182
+ if agent.poll() is None:
183
+ agent.terminate()
184
+ try:
185
+ agent.wait(timeout=5)
186
+ except subprocess.TimeoutExpired:
187
+ agent.kill()
188
+ agent.wait(timeout=5)
189
+ except Exception as error:
190
+ first = first or error
191
+ if first is not None:
192
+ first._ancha_phase = "agent"
193
+ if self._recorder is not None:
194
+ self._recorder.infrastructure_error("agent", first, self._record)
195
+ self._recorded_stop_error = first
196
+ raise first
197
+
198
+ def check(self, *checks: dict) -> CheckReport:
199
+ """The world's own checks (its scene or test), or these check objects, e.g. check({"id": "x", "decided": ["mike", "severity"]})."""
200
+ args = [arg for index, item in enumerate(checks) for arg in ("--check", json.dumps({"id": f"check-{index + 1}", **item}))]
201
+ value = run_cli(["check", "--env", self.id, *args], self.cwd, ok=(0, 1))
202
+ if self._record is not None:
203
+ self._record["checks"].append(value)
204
+ report = CheckReport(value)
205
+ # A check the world couldn't evaluate is a mistake in the test, not a verdict on the agent.
206
+ broken = [result for result in report.checks if result.get("error")]
207
+ if broken:
208
+ raise AnchaError("USAGE", "; ".join(f"check {result['id']} could not be evaluated: {result['error']}" for result in broken))
209
+ return report
210
+
211
+ def holds(self, check: dict) -> bool:
212
+ return self.check(check).passed
213
+
214
+ def state(self) -> dict:
215
+ return run_cli(["world", "state", "--env", self.id], self.cwd)
216
+
217
+ def trace(self) -> list:
218
+ return run_cli(["world", "trace", "--env", self.id], self.cwd)
219
+
220
+ def fresh(self) -> World:
221
+ """Another world from the same scene or test."""
222
+ return World(self._args, self.cwd)
223
+
224
+ def down(self) -> None:
225
+ if self._down:
226
+ return
227
+ self._down = True
228
+ first: BaseException | None = None
229
+ try:
230
+ self.stop()
231
+ except BaseException as error:
232
+ first = error
233
+ if self._recorder is not None and error is not self._recorded_stop_error:
234
+ self._recorder.infrastructure_error("agent", error, self._record)
235
+ if self._record is not None:
236
+ try:
237
+ capture = run_cli(["world", "capture", "--env", self.id], self.cwd)
238
+ # Native state/trace are evidence; free-form service logs are not part of the pytest record.
239
+ if isinstance(capture, dict) and isinstance(capture.get("observation"), dict):
240
+ capture["observation"]["logs"] = []
241
+ self._record["capture"] = capture
242
+ except BaseException as error:
243
+ first = first or error
244
+ self._recorder.infrastructure_error("capture", error, self._record)
245
+ try:
246
+ run_cli(["world", "down", "--env", self.id], self.cwd)
247
+ except BaseException as error:
248
+ first = first or error
249
+ if self._recorder is not None:
250
+ self._recorder.infrastructure_error("cleanup", error, self._record)
251
+ if first is not None:
252
+ if not isinstance(first, Exception):
253
+ raise first
254
+ # The record keeps stage/code evidence. Do not attach raw output or exception text to teardown errors.
255
+ raise AnchaError(error_code(first), "could not capture or remove this test's world") from None
256
+
257
+
258
+ def attempts(n: int, min_pass_rate: float = 1.0) -> Callable:
259
+ """Runs the test `n` times, each in a fresh world, and passes when at least `min_pass_rate` of them pass."""
260
+ if n < 1 or not 0 <= min_pass_rate <= 1:
261
+ raise ValueError("attempts(n, min_pass_rate): n >= 1 and 0 <= min_pass_rate <= 1")
262
+
263
+ def decorate(test: Callable) -> Callable:
264
+ @functools.wraps(test)
265
+ def wrapper(*args: Any, **kwargs: Any) -> None:
266
+ import pytest
267
+ first: World | None = kwargs.get("world")
268
+ if first is None:
269
+ raise TypeError("ancha.attempts needs the world fixture: def test_x(world)")
270
+ passed, failures = 0, []
271
+ recorder = current.get()
272
+ if recorder is not None:
273
+ recorder.repeated = True
274
+ recorder.payload["minPassRate"] = min_pass_rate
275
+ recorder.payload["requestedTrials"] = n
276
+ for attempt in range(n):
277
+ if attempt > 0 and recorder is not None:
278
+ recorder.begin_attempt()
279
+ world = first if attempt == 0 else first.fresh()
280
+ outcome = "passed"
281
+ raised: BaseException | None = None
282
+ try:
283
+ test(*args, **{**kwargs, "world": world})
284
+ passed += 1
285
+ except (AssertionError, pytest.fail.Exception) as error:
286
+ outcome = "failed"
287
+ failures.append(f"attempt {attempt + 1}: {error}")
288
+ except BaseException as error:
289
+ outcome = "xfailed" if isinstance(error, pytest.xfail.Exception) else "skipped" if isinstance(error, pytest.skip.Exception) else "invalid"
290
+ raised = error
291
+ finally:
292
+ cleanup = recorder.finish_attempt(outcome) if recorder is not None else None
293
+ if recorder is None:
294
+ try:
295
+ world.down()
296
+ except BaseException as error:
297
+ cleanup = error
298
+ if raised is not None:
299
+ raise raised
300
+ if cleanup is not None:
301
+ raise cleanup
302
+ from fractions import Fraction
303
+ threshold = Fraction(str(min_pass_rate))
304
+ if passed * threshold.denominator < n * threshold.numerator:
305
+ raise AssertionError(f"{passed} of {n} attempts passed; at least {min_pass_rate:.0%} must. " + " | ".join(failures[:3]))
306
+
307
+ return wrapper
308
+
309
+ return decorate
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: ancha
3
+ Version: 0.1.0
4
+ Summary: Ancha for Python: test your agent's own tools against production's recorded calls, and pytest worlds for agent tests
5
+ License-Expression: MIT
6
+ Classifier: Framework :: Pytest
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: pytest
10
+ Requires-Dist: pytest>=7; extra == "pytest"
11
+
12
+ # ancha
13
+
14
+ Ancha for Python. Two parts, one `import ancha`:
15
+
16
+ - **Tool wrappers** (no dependencies): wrap your agent's own tools once with `@ancha.tool` or `ancha.wrap_tools`, and
17
+ run the agent through `ancha.entry`. In production nothing changes. In an [Ancha](https://ancha.io) test made from a
18
+ production trace, each call answers as production did. See
19
+ [Tool wrappers](https://docs.ancha.io/reference/tool-wrappers).
20
+ - **A pytest plugin** (`pip install "ancha[pytest]"`): a fresh simulated world for each test. See
21
+ [Test with pytest](https://docs.ancha.io/guides/pytest).
22
+
23
+ ```sh
24
+ pip install ancha
25
+ ```
26
+
27
+ ```python
28
+ import ancha
29
+
30
+ @ancha.tool
31
+ def lookup_order(order_id: str) -> dict: ...
32
+
33
+ ancha.entry(lambda input: run_agent(input["task"]))
34
+ ```
35
+
36
+ ## The pytest plugin
37
+
38
+ pytest for Ancha: a fresh simulated world for each test, your agent run against it, and checks on what people learned and what the tools hold. It drives the `ancha` CLI. This development version needs the matching CLI with `world capture` and `world record`; published CLI 0.1.4 does not support run recording. `ANCHA_CLI` names another CLI command.
39
+
40
+ ```python
41
+ import ancha, pytest
42
+
43
+ @pytest.mark.ancha(test="ancha/tests/relay-export-bug.yaml")
44
+ @ancha.attempts(10, min_pass_rate=0.9)
45
+ def test_relay(world):
46
+ world.start("npm start") # the agent gets world.env: the tools' URLs and fake credentials, ANCHA_TASK
47
+ world.wait_until_quiet()
48
+ assert world.people["ben"].knows("laura.beta_on")
49
+ assert world.slack.messages(to="laura", in_thread_of="$laura_report", from_="$slack_bot")
50
+ assert not world.people["laura"].knows("ben.root_cause")
51
+ ```
52
+
53
+ - `@pytest.mark.ancha(test=…, scene=…, world=…, on="local"|"cloud", seed=…, ttl=…)` says what the `world` fixture starts (`ancha world up`). Without it, the one scene `ancha.yaml` names.
54
+ - `world.env`, `world.task`; `world.start(command)`, `world.wait_until_quiet(timeout)`, `world.log()`, `world.stop()`.
55
+ - `world.check()` applies the test's own checks; `world.check({...})` applies check objects (the kinds in `ancha docs scenes`). A report is truthy when every check passed; `report.failures` has expected and actual.
56
+ - `world.people[ref].knows(fact)`, `.decided(decision, option)`; `world.slack.messages(to=, in_=, in_thread_of=, from_=, text_contains=, fact=)`.
57
+ - `world.state()`, `world.trace()`; `world.fresh()` for another world; the fixture removes its world (`ancha world down`).
58
+ - `@ancha.attempts(n, min_pass_rate)` runs the test in `n` fresh worlds and passes when enough of them do.
59
+
60
+ An evaluated check that fails returns a false `CheckReport`; `report.failures` explains the expected and actual values. A check that cannot be evaluated raises `ancha.AnchaError` instead. For example, a `knows` check naming an unknown person raises an error with `code == "USAGE"` and a message naming the invalid reference. Correct the test definition; this error is not a verdict on the agent. The people and Slack helpers use the same check path and can raise the same error. CLI failures also raise `AnchaError`, retaining the CLI's `code`, `message` and optional `hint`.
61
+
62
+ Worlds start locally by default, including after `ancha login`. Run `pytest --on cloud` to put worlds in your hosted workspace; pytest and the agent still run on your machine. `--on local` forces local worlds. The pytest option overrides the marker's `on="local"` or `on="cloud"`; without either, the CLI's normal world selection applies. An explicit server URL can also select a world server. Never give the agent the test files or the checks.
63
+
64
+ ## Recorded runs
65
+
66
+ While the plugin is enabled, each executed pytest item creates one local run under `.ancha/runs/`, with `source: pytest` and `target: local`. This includes tests without a world, parameterized cases and skipped tests. Collection alone creates no records. Disable the plugin with `-p no:ancha` for suites that should not record. Run files stay local even when worlds run in the cloud; uploading pytest records is not supported.
67
+
68
+ ```sh
69
+ ancha --local runs list --json
70
+ ancha --local runs show <run-id> --json
71
+ ancha --local explain <run-id> --json
72
+ ```
73
+
74
+ Pytest assertions decide the test outcome. A deliberately false `world.check()` remains evidence and does not fail a passing negative assertion. Records preserve setup, call and teardown outcomes separately. Skipped and expected-failure cases are ungraded; setup, capture and cleanup failures are invalid, not failed agent answers. `@ancha.attempts` records each invocation and its fresh world under the same run, including failed attempts when the overall pass-rate threshold is met. The requested repetition count is recorded before execution. Reports keep every requested attempt in the pass-rate denominator, including invalid, skipped and missing attempts; all-k requires all requested attempts to finish validly and pass. Thresholds use exact integer counts. An old baseline without verified requested counts must be regenerated before comparison.
75
+
76
+ The plugin stops its agent, captures a coherent world observation, then removes the world. This observation is not a sealed managed-trial capture. Capture failures remain visible and cleanup is still attempted. Recording failures are reported and make an otherwise successful pytest session fail.
77
+
78
+ Parameter values in test IDs are replaced by a hash. Records omit environment values, raw exception messages, captured pytest output and agent logs. Known credentials are redacted from evidence; keep `.ancha/` private and out of version control. Abrupt termination such as SIGKILL cannot finish a record or guarantee cleanup; world TTLs still apply.
@@ -0,0 +1,10 @@
1
+ ancha/__init__.py,sha256=NZ3HpuooOdG1eDMYe5fZWtAdo5wB7RsgjF5k3FFUH2E,1086
2
+ ancha/plugin.py,sha256=4NqKTFDfaD7oF0pZFtuydPfTndGewFoVu2N80qdGxw8,3787
3
+ ancha/recording.py,sha256=S9fBn5pnkV17euWLovniSoaqINfIZja8lRFvoYjTpO8,7745
4
+ ancha/tools.py,sha256=c62jEwTBxKumuRCV0QkwgKrujPJF6ruoU1n8-JgC2nw,10445
5
+ ancha/world.py,sha256=vW8lGMCB21Yl5yt4NGQy_omS-2WsTbfPqpm63fRnn-E,13982
6
+ ancha-0.1.0.dist-info/METADATA,sha256=a1eQTpQ4CujCTQElbYvuRa9P30RetXFpKXo9fU0puWE,6005
7
+ ancha-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ ancha-0.1.0.dist-info/entry_points.txt,sha256=Ce3WFSBftLgXwuUUi3ME1dzUTuXKauOuq-Zyl8w49XM,32
9
+ ancha-0.1.0.dist-info/top_level.txt,sha256=cYaGV4PP4Q52VJVUOUxGbXTxNhFB_P533AVWGRUlIe4,6
10
+ ancha-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ ancha = ancha.plugin
@@ -0,0 +1 @@
1
+ ancha