@ancha-labs/tools 0.0.0-stage → 0.1.0

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.
package/README.md CHANGED
@@ -1,3 +1,25 @@
1
- # Temporary Holding Version
1
+ # @ancha-labs/tools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Test your agent against the production run that went wrong. Wrap your agent's own tools once: in production nothing
4
+ changes, and in an [Ancha](https://ancha.io) test made from a production trace each call answers as production did.
5
+
6
+ ```sh
7
+ npm install @ancha-labs/tools
8
+ ```
9
+
10
+ ```ts
11
+ import { wrapTools, entry } from '@ancha-labs/tools';
12
+
13
+ const tools = wrapTools({ lookup_order, refund_order }); // functions, or the AI SDK's tool() objects
14
+
15
+ await entry(async input => runAgent(input.task, tools)); // the command an Ancha test runs
16
+ ```
17
+
18
+ - **Outside a test** (no `ANCHA_TOOLS_URL`), a wrapped tool is your function: same arguments, same result, no network.
19
+ - **In a test**, `ancha run` answers each call: production's answer (`replayed`), a model's answer in production's shape
20
+ for a read production never made (`simulated`), or an error for a write production never made (`failed`).
21
+ - **A tool the trace recorded but your agent does not wrap** stops the test before your agent runs, so a test never
22
+ reaches production.
23
+
24
+ Docs: [Tool wrappers](https://docs.ancha.io/reference/tool-wrappers) and
25
+ [From a production trace to a failing test](https://docs.ancha.io/guides/rebuild-from-traces). MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "@ancha-labs/tools",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Test your agent's own tools against production's recorded calls: wrap them once, and in an Ancha test each call answers as production did. Nothing changes in production.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.mjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "license": "MIT",
19
+ "homepage": "https://docs.ancha.io",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Ancha answered a tool call with the error production's tool raised, or with no recorded answer. */
2
+ export declare class AnchaToolError extends Error {
3
+ /** Where the answer came from: `replayed` (production's error) or `failed` (no recorded answer). */
4
+ source?: 'replayed' | 'twin' | 'simulated' | 'failed';
5
+ }
6
+
7
+ /** What a test hands the agent: the invocation's task, the conversation so far, and the user it served. */
8
+ export interface AgentInput {
9
+ task: string;
10
+ messages?: { role: 'user' | 'assistant' | 'tool'; content: string | null; tool_calls?: { id?: string; name: string; arguments: unknown }[]; tool_call_id?: string }[];
11
+ user?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ /** One function as a tool named `name`, the name the model sees and the trace records. */
16
+ export declare function wrapTool<F extends (...args: any[]) => any>(name: string, fn: F): (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>>;
17
+
18
+ /** Every tool in `tools`, wrapped: functions, and tool objects with `execute` (the AI SDK's `tool()`). */
19
+ export declare function wrapTools<T extends Record<string, unknown> | unknown[]>(tools: T, options?: { names?: Record<string, string> }): T;
20
+
21
+ /** One call to a tool as Ancha answers it in a test. */
22
+ export declare function callTool(name: string, args: unknown): Promise<unknown>;
23
+
24
+ /** The command a test runs: checks every tool is wrapped, calls `run` with the invocation's input, returns its answer to Ancha. */
25
+ export declare function entry<T>(run: (input: AgentInput) => T | Promise<T>): Promise<T>;
package/src/index.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // @ancha-labs/tools: test an agent's own tools against production's recorded calls (TRACE_PLAN_V2 §3).
2
+ //
3
+ // import { wrapTools, entry } from '@ancha-labs/tools';
4
+ // const tools = wrapTools({ lookupOrder, refundOrder }); // or AI SDK tools: wrapTools({ weather: tool({ … }) })
5
+ // entry(input => runAgent(input.messages, input.user)); // in the command ancha.yaml runs
6
+ //
7
+ // In production nothing changes: without ANCHA_TOOLS_URL every wrapped tool is the original function. In a test,
8
+ // `ancha run` sets ANCHA_TOOLS_URL and ANCHA_TOOLS_TOKEN, and each call goes to Ancha, which answers as production did.
9
+
10
+ /** Ancha answered a tool call with the error production's tool raised, or with no recorded answer. */
11
+ export class AnchaToolError extends Error {
12
+ constructor(message, source) { super(message); this.name = 'AnchaToolError'; this.source = source; }
13
+ }
14
+
15
+ const wrapped = new Set();
16
+ let registered = Promise.resolve();
17
+ const service = () => (process.env.ANCHA_TOOLS_URL ? { url: process.env.ANCHA_TOOLS_URL.replace(/\/+$/, ''), token: process.env.ANCHA_TOOLS_TOKEN ?? '' } : undefined);
18
+
19
+ async function post(url, token, body) {
20
+ const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body) });
21
+ const answer = await res.json().catch(() => ({}));
22
+ return { status: res.status, body: answer };
23
+ }
24
+
25
+ /** Tells Ancha which tools this process wraps, so a test stops before the agent calls an unwrapped one for real. */
26
+ function register(names) {
27
+ for (const name of names) wrapped.add(name);
28
+ const s = service();
29
+ if (!s) return;
30
+ const previous = registered;
31
+ registered = previous.then(() => post(`${s.url}/register`, s.token, { tools: names }), () => {}).then(() => {}, () => {});
32
+ }
33
+
34
+ /** Ancha's answer to one call: production's result, its error thrown as the tool would have, or `passthrough`. */
35
+ async function ask(name, args) {
36
+ const s = service();
37
+ if (!s) throw new Error('callTool runs inside an Ancha test: ANCHA_TOOLS_URL is not set');
38
+ await registered;
39
+ const { status, body } = await post(`${s.url}/tools/${encodeURIComponent(name)}`, s.token, { args: args ?? {} });
40
+ if (status === 401) throw new Error('Ancha refused the tool call: ANCHA_TOOLS_TOKEN does not match this test');
41
+ if (body?.stop) { process.stderr.write(`ancha: ${body.error}\n`); process.exit(2); }
42
+ if (status >= 400) throw new Error(`Ancha's tool service answered ${status}${body?.error ? `: ${body.error}` : ''}`);
43
+ if (body.error !== undefined) throw new AnchaToolError(body.error, body.source);
44
+ return body;
45
+ }
46
+
47
+ /** One call to a tool, answered by Ancha: the recorded result, or the recorded error thrown as the tool would have. */
48
+ export async function callTool(name, args) {
49
+ const answer = await ask(name, args);
50
+ if (answer.passthrough) throw new AnchaToolError(`production never made this call to ${name}; the tool itself runs against the ${answer.source} in a test`, answer.source);
51
+ return answer.result;
52
+ }
53
+
54
+ /** A call through Ancha, or, when production never made it and the tool is linked to a twin, the tool itself. */
55
+ async function route(name, args, original) {
56
+ const answer = await ask(name, args);
57
+ return answer.passthrough ? original() : answer.result;
58
+ }
59
+
60
+ /** The arguments as the model sent them: a tool's one object argument, otherwise the list of arguments. */
61
+ const argsOf = list => (list.length === 1 && list[0] !== null && typeof list[0] === 'object' && !Array.isArray(list[0]) ? list[0] : list.length === 0 ? {} : { args: list });
62
+
63
+ /** One function as a tool named `name` (the name the model sees, as the trace records it). */
64
+ export function wrapTool(name, fn) {
65
+ if (typeof fn !== 'function') throw new TypeError(`wrapTool(${JSON.stringify(name)}): expected a function`);
66
+ register([name]);
67
+ const tool = async function (...args) {
68
+ return service() ? route(name, argsOf(args), () => fn.apply(this, args)) : fn.apply(this, args);
69
+ };
70
+ Object.defineProperty(tool, 'name', { value: fn.name || name });
71
+ return tool;
72
+ }
73
+
74
+ /**
75
+ * Every tool in `tools`, wrapped: functions, and tool objects with an `execute` function (the AI SDK's `tool()`),
76
+ * whose `execute(input, options)` is called with the model's input. The keys are the names the model sees;
77
+ * `names` maps a key to another name when the trace knows the tool by one.
78
+ */
79
+ export function wrapTools(tools, { names = {} } = {}) {
80
+ const out = Array.isArray(tools) ? [] : {};
81
+ const list = [];
82
+ for (const [key, value] of Object.entries(tools)) {
83
+ const toolName = names[key] ?? (Array.isArray(tools) ? value?.name || key : key);
84
+ if (typeof value === 'function') out[key] = wrapTool(toolName, value);
85
+ else if (value && typeof value.execute === 'function') {
86
+ const execute = value.execute;
87
+ out[key] = { ...value, execute: async (input, options) => (service() ? route(toolName, input, () => execute.call(value, input, options)) : execute.call(value, input, options)) };
88
+ list.push(toolName);
89
+ } else throw new TypeError(`wrapTools: ${key} is neither a function nor a tool with execute()`);
90
+ }
91
+ register(list);
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * The command a test runs: tells Ancha every wrapped tool (a test stops here if the trace holds one the agent does
97
+ * not wrap), calls `run` with the invocation's input (ANCHA_INPUT_JSON: the conversation so far and the user, plus
98
+ * `task`), and hands the answer back to Ancha. Outside a test it runs `run` with ANCHA_TASK, or with nothing.
99
+ */
100
+ export async function entry(run) {
101
+ const s = service();
102
+ if (s) {
103
+ await registered;
104
+ const { status, body } = await post(`${s.url}/register`, s.token, { tools: [...wrapped], complete: true });
105
+ if (status === 409) { process.stderr.write(`ancha: ${body.error}\n`); process.exit(2); }
106
+ }
107
+ const input = { task: process.env.ANCHA_TASK ?? '', ...JSON.parse(process.env.ANCHA_INPUT_JSON || '{}') };
108
+ const output = await run(input);
109
+ if (process.env.ANCHA_DONE_URL) await post(process.env.ANCHA_DONE_URL, process.env.ANCHA_TRIAL_TOKEN ?? '', { output: output ?? null });
110
+ return output;
111
+ }