@animalabs/agent-framework 0.7.0 → 0.7.3
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/dist/src/code-execution/index.d.ts +14 -0
- package/dist/src/code-execution/index.d.ts.map +1 -0
- package/dist/src/code-execution/index.js +13 -0
- package/dist/src/code-execution/index.js.map +1 -0
- package/dist/src/code-execution/py-runner.d.ts +96 -0
- package/dist/src/code-execution/py-runner.d.ts.map +1 -0
- package/dist/src/code-execution/py-runner.js +371 -0
- package/dist/src/code-execution/py-runner.js.map +1 -0
- package/dist/src/code-execution/runtime-py.d.ts +32 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -0
- package/dist/src/code-execution/runtime-py.js +265 -0
- package/dist/src/code-execution/runtime-py.js.map +1 -0
- package/dist/src/code-execution/tool-definition.d.ts +16 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -0
- package/dist/src/code-execution/tool-definition.js +47 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -0
- package/dist/src/framework.d.ts +43 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +274 -26
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +12 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +45 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +24 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -0
- package/dist/src/mcpl/prose-grammar.js +33 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -0
- package/dist/src/mcpl/prose-stream-router.d.ts +58 -0
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -0
- package/dist/src/mcpl/prose-stream-router.js +168 -0
- package/dist/src/mcpl/prose-stream-router.js.map +1 -0
- package/dist/src/types/framework.d.ts +33 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/test/code-execution.test.d.ts +11 -0
- package/dist/test/code-execution.test.d.ts.map +1 -0
- package/dist/test/code-execution.test.js +423 -0
- package/dist/test/code-execution.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.d.ts +2 -0
- package/dist/test/prose-stream-router.test.d.ts.map +1 -0
- package/dist/test/prose-stream-router.test.js +87 -0
- package/dist/test/prose-stream-router.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/code-execution/index.ts +19 -0
- package/src/code-execution/py-runner.ts +439 -0
- package/src/code-execution/runtime-py.ts +265 -0
- package/src/code-execution/tool-definition.ts +54 -0
- package/src/framework.ts +307 -28
- package/src/index.ts +17 -0
- package/src/mcpl/channel-registry.ts +53 -0
- package/src/mcpl/prose-grammar.ts +40 -0
- package/src/mcpl/prose-stream-router.ts +161 -0
- package/src/types/framework.ts +35 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python runtime for client-side programmatic tool calling (PTC).
|
|
3
|
+
*
|
|
4
|
+
* This is the interpreter-side half of the `code_execution` tool: a small
|
|
5
|
+
* asyncio REPL that mimics the environment Anthropic's server-side PTC was
|
|
6
|
+
* trained on (see docs/en/agents-and-tools/tool-use/programmatic-tool-calling):
|
|
7
|
+
*
|
|
8
|
+
* - scripts run with top-level `await` (PyCF_ALLOW_TOP_LEVEL_AWAIT)
|
|
9
|
+
* - host tools are injected as async functions taking a single dict and
|
|
10
|
+
* returning a string (the tool_result text)
|
|
11
|
+
* - a pending tool call that gets no response raises TimeoutError with the
|
|
12
|
+
* same message shape the managed runtime produces
|
|
13
|
+
* - interpreter globals persist across execs (container-reuse semantics)
|
|
14
|
+
*
|
|
15
|
+
* Embedded as a TS string because tsc does not copy non-TS assets to dist/;
|
|
16
|
+
* PyRunner materializes it to a temp file at spawn.
|
|
17
|
+
*
|
|
18
|
+
* Protocol (JSON lines):
|
|
19
|
+
* host -> py : {op:"init", tools:[{py_name,tool_name}], call_timeout_s}
|
|
20
|
+
* {op:"exec", id, code}
|
|
21
|
+
* {op:"tool_result", id, result}
|
|
22
|
+
* {op:"cancel", id, reason?}
|
|
23
|
+
* py -> host : {op:"ready"}
|
|
24
|
+
* {op:"tool_call", id, exec_id, name, args}
|
|
25
|
+
* {op:"exec_result", id, stdout, stderr, return_code}
|
|
26
|
+
*
|
|
27
|
+
* IMPORTANT: the python source below must not contain backticks or the
|
|
28
|
+
* sequence dollar+brace (TS template literal syntax). String.raw preserves
|
|
29
|
+
* backslashes, so \n inside python string literals is fine.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const PYTHON_RUNTIME_SOURCE: string = String.raw`
|
|
33
|
+
import ast
|
|
34
|
+
import asyncio
|
|
35
|
+
import inspect
|
|
36
|
+
import io
|
|
37
|
+
import json
|
|
38
|
+
import sys
|
|
39
|
+
import threading
|
|
40
|
+
import traceback
|
|
41
|
+
|
|
42
|
+
PROTO_OUT = sys.stdout # protocol channel (real stdout)
|
|
43
|
+
REAL_STDERR = sys.stderr # runtime diagnostics outside execs
|
|
44
|
+
CALL_TIMEOUT_S = 270.0
|
|
45
|
+
OUTPUT_CAP_CHARS = 2_000_000
|
|
46
|
+
|
|
47
|
+
_send_lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def send(obj):
|
|
51
|
+
line = json.dumps(obj, ensure_ascii=False, default=str)
|
|
52
|
+
with _send_lock:
|
|
53
|
+
PROTO_OUT.write(line + "\n")
|
|
54
|
+
PROTO_OUT.flush()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CappedIO(io.StringIO):
|
|
58
|
+
"""StringIO that stops recording past a cap (keeps a truncation flag)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, cap):
|
|
61
|
+
super().__init__()
|
|
62
|
+
self._cap = cap
|
|
63
|
+
self.truncated = False
|
|
64
|
+
|
|
65
|
+
def write(self, s):
|
|
66
|
+
if not isinstance(s, str):
|
|
67
|
+
s = str(s)
|
|
68
|
+
if self.tell() >= self._cap:
|
|
69
|
+
self.truncated = True
|
|
70
|
+
return len(s)
|
|
71
|
+
remaining = self._cap - self.tell()
|
|
72
|
+
if len(s) > remaining:
|
|
73
|
+
self.truncated = True
|
|
74
|
+
return super().write(s[:remaining])
|
|
75
|
+
return super().write(s)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Persistent interpreter state (mirrors container reuse: variables survive
|
|
79
|
+
# between exec calls until the host reclaims the interpreter).
|
|
80
|
+
SCRIPT_GLOBALS = {"__name__": "__main__", "__builtins__": __builtins__}
|
|
81
|
+
_injected_py_names = set()
|
|
82
|
+
|
|
83
|
+
_pending_tool_futures = {}
|
|
84
|
+
_next_tool_call_id = 0
|
|
85
|
+
_current_exec_id = None
|
|
86
|
+
_current_exec_task = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _make_tool_fn(tool_name, py_name):
|
|
90
|
+
async def tool_fn(args=None):
|
|
91
|
+
global _next_tool_call_id
|
|
92
|
+
if args is None:
|
|
93
|
+
args = {}
|
|
94
|
+
if not isinstance(args, dict):
|
|
95
|
+
raise TypeError(
|
|
96
|
+
"tool functions take a single dict of arguments, e.g. await "
|
|
97
|
+
+ py_name + "({...})"
|
|
98
|
+
)
|
|
99
|
+
_next_tool_call_id += 1
|
|
100
|
+
call_id = "t" + str(_next_tool_call_id)
|
|
101
|
+
fut = asyncio.get_running_loop().create_future()
|
|
102
|
+
_pending_tool_futures[call_id] = fut
|
|
103
|
+
send({
|
|
104
|
+
"op": "tool_call",
|
|
105
|
+
"id": call_id,
|
|
106
|
+
"exec_id": _current_exec_id,
|
|
107
|
+
"name": tool_name,
|
|
108
|
+
"args": args,
|
|
109
|
+
})
|
|
110
|
+
try:
|
|
111
|
+
return await asyncio.wait_for(fut, timeout=CALL_TIMEOUT_S)
|
|
112
|
+
except asyncio.TimeoutError:
|
|
113
|
+
raise TimeoutError(
|
|
114
|
+
"Calling tool ['" + tool_name + "'] timed out (no response after "
|
|
115
|
+
+ str(int(CALL_TIMEOUT_S)) + "s)."
|
|
116
|
+
)
|
|
117
|
+
finally:
|
|
118
|
+
_pending_tool_futures.pop(call_id, None)
|
|
119
|
+
|
|
120
|
+
tool_fn.__name__ = py_name
|
|
121
|
+
tool_fn.__qualname__ = py_name
|
|
122
|
+
tool_fn.__doc__ = (
|
|
123
|
+
"Host tool '" + tool_name + "'. Takes a single dict of arguments and "
|
|
124
|
+
"returns the tool result as a string."
|
|
125
|
+
)
|
|
126
|
+
return tool_fn
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def handle_init(msg):
|
|
130
|
+
global CALL_TIMEOUT_S
|
|
131
|
+
timeout = msg.get("call_timeout_s")
|
|
132
|
+
if isinstance(timeout, (int, float)) and timeout > 0:
|
|
133
|
+
CALL_TIMEOUT_S = float(timeout)
|
|
134
|
+
|
|
135
|
+
# Remove functions injected by a previous init that are no longer present
|
|
136
|
+
# (tool list changed) without touching user-defined globals.
|
|
137
|
+
new_tools = msg.get("tools") or []
|
|
138
|
+
new_py_names = set()
|
|
139
|
+
tools_dict = {}
|
|
140
|
+
for entry in new_tools:
|
|
141
|
+
py_name = entry.get("py_name")
|
|
142
|
+
tool_name = entry.get("tool_name")
|
|
143
|
+
if not py_name or not tool_name:
|
|
144
|
+
continue
|
|
145
|
+
fn = _make_tool_fn(tool_name, py_name)
|
|
146
|
+
SCRIPT_GLOBALS[py_name] = fn
|
|
147
|
+
tools_dict[tool_name] = fn
|
|
148
|
+
new_py_names.add(py_name)
|
|
149
|
+
for stale in _injected_py_names - new_py_names:
|
|
150
|
+
SCRIPT_GLOBALS.pop(stale, None)
|
|
151
|
+
_injected_py_names.clear()
|
|
152
|
+
_injected_py_names.update(new_py_names)
|
|
153
|
+
# Exact-name escape hatch: tools["mcpl--server--tool"]({...})
|
|
154
|
+
SCRIPT_GLOBALS["tools"] = tools_dict
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _run_script(exec_id, code):
|
|
158
|
+
global _current_exec_id, _current_exec_task
|
|
159
|
+
out = CappedIO(OUTPUT_CAP_CHARS)
|
|
160
|
+
err = CappedIO(OUTPUT_CAP_CHARS)
|
|
161
|
+
old_out, old_err, old_in = sys.stdout, sys.stderr, sys.stdin
|
|
162
|
+
sys.stdout, sys.stderr = out, err
|
|
163
|
+
sys.stdin = io.StringIO("") # input() must not steal the protocol channel
|
|
164
|
+
return_code = 0
|
|
165
|
+
try:
|
|
166
|
+
compiled = compile(code, "<script>", "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT)
|
|
167
|
+
result = eval(compiled, SCRIPT_GLOBALS)
|
|
168
|
+
if inspect.iscoroutine(result):
|
|
169
|
+
await result
|
|
170
|
+
except asyncio.CancelledError:
|
|
171
|
+
err.write("\nKeyboardInterrupt: script cancelled by host\n")
|
|
172
|
+
return_code = 1
|
|
173
|
+
except BaseException:
|
|
174
|
+
traceback.print_exc(file=err)
|
|
175
|
+
return_code = 1
|
|
176
|
+
finally:
|
|
177
|
+
sys.stdout, sys.stderr, sys.stdin = old_out, old_err, old_in
|
|
178
|
+
_current_exec_id = None
|
|
179
|
+
_current_exec_task = None
|
|
180
|
+
# Fail any tool futures still pending: their exec is gone.
|
|
181
|
+
for fut in list(_pending_tool_futures.values()):
|
|
182
|
+
if not fut.done():
|
|
183
|
+
fut.cancel()
|
|
184
|
+
_pending_tool_futures.clear()
|
|
185
|
+
|
|
186
|
+
stdout_text = out.getvalue()
|
|
187
|
+
if out.truncated:
|
|
188
|
+
stdout_text += "\n[stdout truncated at " + str(OUTPUT_CAP_CHARS) + " chars]"
|
|
189
|
+
stderr_text = err.getvalue()
|
|
190
|
+
if err.truncated:
|
|
191
|
+
stderr_text += "\n[stderr truncated at " + str(OUTPUT_CAP_CHARS) + " chars]"
|
|
192
|
+
send({
|
|
193
|
+
"op": "exec_result",
|
|
194
|
+
"id": exec_id,
|
|
195
|
+
"stdout": stdout_text,
|
|
196
|
+
"stderr": stderr_text,
|
|
197
|
+
"return_code": return_code,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def main():
|
|
202
|
+
global _current_exec_id, _current_exec_task
|
|
203
|
+
loop = asyncio.get_running_loop()
|
|
204
|
+
queue = asyncio.Queue()
|
|
205
|
+
|
|
206
|
+
def reader():
|
|
207
|
+
for raw_line in sys.stdin:
|
|
208
|
+
loop.call_soon_threadsafe(queue.put_nowait, raw_line)
|
|
209
|
+
loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
210
|
+
|
|
211
|
+
threading.Thread(target=reader, daemon=True).start()
|
|
212
|
+
send({"op": "ready"})
|
|
213
|
+
|
|
214
|
+
while True:
|
|
215
|
+
line = await queue.get()
|
|
216
|
+
if line is None:
|
|
217
|
+
break
|
|
218
|
+
line = line.strip()
|
|
219
|
+
if not line:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
msg = json.loads(line)
|
|
223
|
+
except Exception:
|
|
224
|
+
REAL_STDERR.write("[pytc-runtime] bad protocol line\n")
|
|
225
|
+
REAL_STDERR.flush()
|
|
226
|
+
continue
|
|
227
|
+
op = msg.get("op")
|
|
228
|
+
if op == "init":
|
|
229
|
+
handle_init(msg)
|
|
230
|
+
elif op == "exec":
|
|
231
|
+
if _current_exec_task is not None and not _current_exec_task.done():
|
|
232
|
+
send({
|
|
233
|
+
"op": "exec_result",
|
|
234
|
+
"id": msg.get("id"),
|
|
235
|
+
"stdout": "",
|
|
236
|
+
"stderr": "RuntimeError: another script is already running in this interpreter",
|
|
237
|
+
"return_code": 1,
|
|
238
|
+
})
|
|
239
|
+
continue
|
|
240
|
+
_current_exec_id = msg.get("id")
|
|
241
|
+
_current_exec_task = asyncio.ensure_future(
|
|
242
|
+
_run_script(msg.get("id"), msg.get("code") or "")
|
|
243
|
+
)
|
|
244
|
+
elif op == "tool_result":
|
|
245
|
+
fut = _pending_tool_futures.get(msg.get("id"))
|
|
246
|
+
if fut is not None and not fut.done():
|
|
247
|
+
fut.set_result(str(msg.get("result", "")))
|
|
248
|
+
elif op == "cancel":
|
|
249
|
+
if _current_exec_task is not None and not _current_exec_task.done():
|
|
250
|
+
_current_exec_task.cancel()
|
|
251
|
+
elif op == "exit":
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
# Drain: give a cancelled exec a moment to emit its exec_result.
|
|
255
|
+
if _current_exec_task is not None and not _current_exec_task.done():
|
|
256
|
+
_current_exec_task.cancel()
|
|
257
|
+
try:
|
|
258
|
+
await asyncio.wait_for(_current_exec_task, timeout=2)
|
|
259
|
+
except BaseException:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
asyncio.run(main())
|
|
265
|
+
`;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesized `code_execution` tool definition.
|
|
3
|
+
*
|
|
4
|
+
* The name, input shape ({code}), and description framing deliberately track
|
|
5
|
+
* Anthropic's server-side programmatic tool calling so models' trained
|
|
6
|
+
* priors transfer: python scripts, tools as async functions taking a single
|
|
7
|
+
* dict and returning a string, stdout as the return channel, persistent
|
|
8
|
+
* interpreter state, asyncio.gather for fan-out.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ToolDefinition } from '../types/index.js';
|
|
12
|
+
|
|
13
|
+
export const CODE_EXECUTION_TOOL_NAME = 'code_execution';
|
|
14
|
+
|
|
15
|
+
export function buildCodeExecutionToolDefinition(opts?: {
|
|
16
|
+
idleReclaimMs?: number;
|
|
17
|
+
toolCallTimeoutMs?: number;
|
|
18
|
+
}): ToolDefinition {
|
|
19
|
+
const idleMinutes = Math.max(1, Math.round((opts?.idleReclaimMs ?? 300_000) / 60_000));
|
|
20
|
+
const callTimeoutSeconds = Math.round((opts?.toolCallTimeoutMs ?? 270_000) / 1000);
|
|
21
|
+
return {
|
|
22
|
+
name: CODE_EXECUTION_TOOL_NAME,
|
|
23
|
+
description:
|
|
24
|
+
'Run Python code that can call your other tools programmatically. ' +
|
|
25
|
+
'Every tool you have is available inside the script as an async Python function: ' +
|
|
26
|
+
"the function name is the tool name with '--' replaced by '__' and any other " +
|
|
27
|
+
"non-identifier character replaced by '_' " +
|
|
28
|
+
"(e.g. tool 'mcpl--discord--fetch_history' is the function mcpl__discord__fetch_history, " +
|
|
29
|
+
"and tool 'mcpl--dog-events--status' is mcpl__dog_events__status). " +
|
|
30
|
+
'Each function takes a single dict of arguments and returns a string — the same text ' +
|
|
31
|
+
'the tool would have returned to you directly; parse structured results with json.loads. ' +
|
|
32
|
+
"An exact-name lookup dict is also available: tools['mcpl--discord--fetch_history']({...}). " +
|
|
33
|
+
'Use top-level await; run independent calls in parallel with asyncio.gather. ' +
|
|
34
|
+
'Only what you print() (plus stderr and the exit code) comes back to you — intermediate ' +
|
|
35
|
+
'tool results stay in the script, so filter or aggregate large data there and print only ' +
|
|
36
|
+
'what you need. Interpreter state (variables, imports) persists across code_execution ' +
|
|
37
|
+
`calls but is reclaimed after ~${idleMinutes} minutes idle. A tool call that receives no ` +
|
|
38
|
+
`response within ~${callTimeoutSeconds}s raises TimeoutError inside the script. ` +
|
|
39
|
+
'Use this when fanning out across many items, looping over tool calls, or when tool ' +
|
|
40
|
+
'results are large and you only need a slice or summary. Call tools directly (not via ' +
|
|
41
|
+
'code) when a single call answers the question or when you need to reason about each ' +
|
|
42
|
+
'result before deciding the next step.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object' as const,
|
|
45
|
+
properties: {
|
|
46
|
+
code: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Python code to execute. Top-level await is allowed.',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: ['code'],
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|