kernelport 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.
- kernelport/__init__.py +3 -0
- kernelport/__main__.py +5 -0
- kernelport/_skills/kernelport/SKILL.md +200 -0
- kernelport/_skills/kernelport/backends/cpu.md +138 -0
- kernelport/_skills/kernelport/backends/hip.md +53 -0
- kernelport/_skills/kernelport/backends/metal.md +76 -0
- kernelport/_skills/kernelport/backends/triton.md +117 -0
- kernelport/_skills/kernelport/backends/webgpu.md +146 -0
- kernelport/_skills/kernelport/common_bugs.md +40 -0
- kernelport/agent.py +178 -0
- kernelport/analyzer/__init__.py +0 -0
- kernelport/analyzer/cuda_parser.py +480 -0
- kernelport/analyzer/models.py +80 -0
- kernelport/analyzer/suggest.py +120 -0
- kernelport/backends/__init__.py +5 -0
- kernelport/backends/base.py +138 -0
- kernelport/backends/cpu/__init__.py +1 -0
- kernelport/backends/cpu/capability.py +25 -0
- kernelport/backends/cpu/config.py +36 -0
- kernelport/backends/cpu/runner.py +67 -0
- kernelport/backends/hip/__init__.py +1 -0
- kernelport/backends/hip/capability.py +34 -0
- kernelport/backends/hip/config.py +28 -0
- kernelport/backends/hip/runner.py +48 -0
- kernelport/backends/metal/__init__.py +1 -0
- kernelport/backends/metal/capability.py +32 -0
- kernelport/backends/metal/config.py +35 -0
- kernelport/backends/metal/runner.py +142 -0
- kernelport/backends/triton/__init__.py +1 -0
- kernelport/backends/triton/capability.py +29 -0
- kernelport/backends/triton/config.py +41 -0
- kernelport/backends/triton/runner.py +44 -0
- kernelport/backends/webgpu/__init__.py +1 -0
- kernelport/backends/webgpu/capability.py +34 -0
- kernelport/backends/webgpu/config.py +38 -0
- kernelport/backends/webgpu/runner.py +285 -0
- kernelport/bench/__init__.py +0 -0
- kernelport/bench/timer.py +76 -0
- kernelport/cli.py +501 -0
- kernelport/doctor.py +285 -0
- kernelport/hardware.py +60 -0
- kernelport/harness/__init__.py +0 -0
- kernelport/harness/compare.py +152 -0
- kernelport/harness/device_source.py +273 -0
- kernelport/harness/expr.py +80 -0
- kernelport/harness/inputs.py +194 -0
- kernelport/harness/reference.py +76 -0
- kernelport/harness/results.py +118 -0
- kernelport/harness/run_cuda.py +126 -0
- kernelport/harness/spec.py +199 -0
- kernelport/harness/tolerances.py +48 -0
- kernelport/harness/verify.py +131 -0
- kernelport/hashing.py +28 -0
- kernelport/mcp_server.py +95 -0
- kernelport/report/__init__.py +1 -0
- kernelport/report/data.py +147 -0
- kernelport/report/html.py +174 -0
- kernelport/report/markdown.py +75 -0
- kernelport/setup_agents/__init__.py +1 -0
- kernelport/setup_agents/backup.py +59 -0
- kernelport/setup_agents/detect.py +78 -0
- kernelport/setup_agents/register.py +302 -0
- kernelport/setup_agents/skills_install.py +31 -0
- kernelport/tools.py +455 -0
- kernelport/tune.py +99 -0
- kernelport/workspace.py +400 -0
- kernelport/zoo.py +176 -0
- kernelport-0.1.0.dist-info/METADATA +228 -0
- kernelport-0.1.0.dist-info/RECORD +72 -0
- kernelport-0.1.0.dist-info/WHEEL +4 -0
- kernelport-0.1.0.dist-info/entry_points.txt +2 -0
- kernelport-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Porting CUDA to WebGPU (WGSL)
|
|
2
|
+
|
|
3
|
+
A WebGPU port is a WGSL compute shader. The mapping from CUDA is close to
|
|
4
|
+
one-to-one: a CUDA thread block is a WGSL **workgroup**, `__shared__` is
|
|
5
|
+
`var<workgroup>`, `__syncthreads()` is `workgroupBarrier()`.
|
|
6
|
+
|
|
7
|
+
kernelport runs ports through **wgpu-native** (Vulkan / Metal / D3D12) with only
|
|
8
|
+
core WebGPU features enabled (plus the standard `shader-f16`), so WGSL that passes
|
|
9
|
+
here is WGSL a browser accepts. Timings are wall-clock including submission and are
|
|
10
|
+
**not comparable** to native kernel times.
|
|
11
|
+
|
|
12
|
+
## Port file format
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
"""WebGPU (WGSL) port of relu: y[i] = max(x[i], 0)."""
|
|
16
|
+
|
|
17
|
+
WGSL = """
|
|
18
|
+
@compute @workgroup_size(256)
|
|
19
|
+
fn main(@builtin(global_invocation_id) gid: vec3<u32>,
|
|
20
|
+
@builtin(num_workgroups) nwg: vec3<u32>) {
|
|
21
|
+
let n = u32(params.n);
|
|
22
|
+
for (var i = gid.x; i < n; i += nwg.x * 256u) { // grid-stride: see the 65535 limit
|
|
23
|
+
let v = x[i];
|
|
24
|
+
y[i] = select(T(0.0), v, v > T(0.0));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def grid(n): # launch.json names; arrays are NumPy (use .shape)
|
|
31
|
+
return (min((n + 255) // 256, 65535),) # workgroups per dimension
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- The entry point is **`fn main`**. `grid(...)` returns 1 to 3 workgroup counts
|
|
35
|
+
(CUDA: the grid); each must be **≤ 65535**.
|
|
36
|
+
- **Do not declare the bindings.** The runner appends them:
|
|
37
|
+
- `alias T = f32;` (`f16` for float16 cases, with `enable f16;`), so write
|
|
38
|
+
`array<T>` code once for every dtype;
|
|
39
|
+
- one storage buffer per tensor param **named exactly like the launch.json param**,
|
|
40
|
+
in launch.json order: `var<storage, read>` for `input`, `read_write` for
|
|
41
|
+
`output`/`inout`. Element type: `T` for dtype "T"; else `f32`, `f16`, `i32`, `u32`;
|
|
42
|
+
- **int64 arrives as `vec2<u32>`** (low word in `.x`); core WGSL has no 64-bit ints.
|
|
43
|
+
Index arrays are small and non-negative: use `ids[t].x`;
|
|
44
|
+
- `params`: a uniform struct with every scalar param (`params.n`, `params.eps`).
|
|
45
|
+
int32 → `i32`, float32 → `f32`. Convert before comparing: `u32(params.n)`.
|
|
46
|
+
- To change a buffer's declared type (atomics), set
|
|
47
|
+
`BINDINGS = {"out": "array<atomic<u32>>"}` in the port.
|
|
48
|
+
- Shader errors are reported with line numbers **inside the WGSL string**.
|
|
49
|
+
- Supported dtypes: float32, float16 (when the adapter has `shader-f16`). bfloat16
|
|
50
|
+
cases are skipped and named in the result (no bf16 type in WGSL).
|
|
51
|
+
|
|
52
|
+
## CUDA → WGSL mapping
|
|
53
|
+
|
|
54
|
+
| CUDA | WGSL |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `__global__ void k(...)` | `@compute @workgroup_size(256) fn main(...)` |
|
|
57
|
+
| `blockDim.x` (256) | the constant in `@workgroup_size` |
|
|
58
|
+
| `blockIdx.x` | `@builtin(workgroup_id) wid: vec3<u32>` → `wid.x` |
|
|
59
|
+
| `threadIdx.x` | `@builtin(local_invocation_id) lid` or `@builtin(local_invocation_index) tid` |
|
|
60
|
+
| `blockIdx.x * blockDim.x + threadIdx.x` | `@builtin(global_invocation_id) gid` → `gid.x` |
|
|
61
|
+
| `gridDim.x` | `@builtin(num_workgroups) nwg` → `nwg.x` |
|
|
62
|
+
| `if (i < n)` | `if (i < u32(params.n)) { ... }` |
|
|
63
|
+
| `__shared__ float red[256];` | `var<workgroup> red: array<f32, 256>;` (module scope) |
|
|
64
|
+
| `__syncthreads()` | `workgroupBarrier()` (must be reached by the whole workgroup: not inside a divergent `if`) |
|
|
65
|
+
| `__shfl_xor_sync` warp reductions | tree reduction in `var<workgroup>` memory (subgroups are optional in WebGPU) |
|
|
66
|
+
| `atomicAdd(int*)` | `atomicAdd(&buf[i], v)` on `array<atomic<i32>>` / `atomic<u32>` |
|
|
67
|
+
| `atomicAdd(float*)` | **no float atomics in core WGSL**: compare-exchange loop on the bits (below) |
|
|
68
|
+
| `expf, logf, sqrtf, rsqrtf, fmaxf` | `exp, log, sqrt, inverseSqrt, max` |
|
|
69
|
+
| `-INFINITY` | no inf literals in WGSL: use `-3.4028235e38` (-FLT_MAX) as the max identity |
|
|
70
|
+
| `(float)x[i]` / `(T)v` | `f32(x[i])` / `T(v)` |
|
|
71
|
+
| `c ? a : b` | `select(b, a, c)` (note the argument order) |
|
|
72
|
+
| `size_t` / `long long` offsets | `u32` (fine below 4G elements) |
|
|
73
|
+
| template `<typename T>` | `alias T` provided by the runner |
|
|
74
|
+
|
|
75
|
+
## Patterns
|
|
76
|
+
|
|
77
|
+
**Element-wise, large n**: one invocation per element only works up to
|
|
78
|
+
65535 × workgroup_size elements. Use a grid-stride loop with the grid capped at
|
|
79
|
+
65535 (shown above), or a 2-D grid.
|
|
80
|
+
|
|
81
|
+
**One workgroup per row** (softmax, layernorm, rmsnorm, cumsum): `grid(rows) =
|
|
82
|
+
(rows,)`; invocations stride over the row (`for (var c = tid; c < cols; c += 256u)`),
|
|
83
|
+
then a workgroup tree reduction:
|
|
84
|
+
|
|
85
|
+
```wgsl
|
|
86
|
+
var<workgroup> red: array<f32, 256>;
|
|
87
|
+
|
|
88
|
+
fn block_sum(v: f32, tid: u32) -> f32 {
|
|
89
|
+
red[tid] = v;
|
|
90
|
+
workgroupBarrier();
|
|
91
|
+
for (var s = 128u; s > 0u; s >>= 1u) {
|
|
92
|
+
if (tid < s) { red[tid] += red[tid + s]; }
|
|
93
|
+
workgroupBarrier(); // outside the if: every invocation reaches it
|
|
94
|
+
}
|
|
95
|
+
let r = red[0];
|
|
96
|
+
workgroupBarrier(); // before red is reused
|
|
97
|
+
return r;
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Float atomicAdd** (reduce_sum): reduce per workgroup, then one invocation adds
|
|
102
|
+
the partial with a compare-exchange loop (`BINDINGS = {"out": "array<atomic<u32>>"}`):
|
|
103
|
+
|
|
104
|
+
```wgsl
|
|
105
|
+
var old = atomicLoad(&out[0]);
|
|
106
|
+
loop {
|
|
107
|
+
let r = atomicCompareExchangeWeak(&out[0], old, bitcast<u32>(bitcast<f32>(old) + partial));
|
|
108
|
+
if (r.exchanged) { break; }
|
|
109
|
+
old = r.old_value;
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Tiles** (transpose, tiled matmul): `var<workgroup> tile: array<array<T, 33>, 32>;`,
|
|
114
|
+
a 2-D `@workgroup_size(32, 8)`, `workgroupBarrier()` between load and store, exactly
|
|
115
|
+
like the CUDA tile.
|
|
116
|
+
|
|
117
|
+
**Scans**: port the CUDA scan as is (Hillis-Steele in workgroup memory + a
|
|
118
|
+
`var<workgroup> carry: f32`), with barriers where CUDA has `__syncthreads`.
|
|
119
|
+
|
|
120
|
+
## Buffer size limits
|
|
121
|
+
|
|
122
|
+
One storage binding is limited by the adapter: browsers default to 128 MiB, software
|
|
123
|
+
Vulkan (llvmpipe) allows 128 MiB, discrete GPUs allow up to 2 GiB here. A case whose
|
|
124
|
+
tensor is larger than the adapter's limit is not run at all: the result names it as
|
|
125
|
+
"cases this device cannot run, not tested" with the sizes. That is a device limit, not
|
|
126
|
+
a bug in your port, so do not change the port for it. Say in your report which cases
|
|
127
|
+
were not tested and why.
|
|
128
|
+
|
|
129
|
+
## Precision notes
|
|
130
|
+
|
|
131
|
+
- Compute in f32 for f16 data (`f32(x[i])`, store with `T(v)`), like CUDA's float
|
|
132
|
+
accumulators.
|
|
133
|
+
- WGSL's `exp`, `log` and `sqrt` precision is implementation-defined (exp: about
|
|
134
|
+
3 + 2·|x| ulp). That is within tolerance when the argument is small, as in
|
|
135
|
+
softmax's `exp(x - max)`. Nothing is loosened for it.
|
|
136
|
+
- There is no `fma` contraction control; plain `a * b + c` is fine.
|
|
137
|
+
|
|
138
|
+
## WebGPU-specific bugs checklist
|
|
139
|
+
|
|
140
|
+
- [ ] No binding declarations in the WGSL (the runner adds them); names match launch.json.
|
|
141
|
+
- [ ] `grid()` returns ≤ 65535 per dimension; large 1-D inputs use a grid-stride loop.
|
|
142
|
+
- [ ] Every `workgroupBarrier()` is in uniform control flow (not inside `if (tid < s)`).
|
|
143
|
+
- [ ] Scalars converted before use: `u32(params.n)`, `f32(params.cols)`.
|
|
144
|
+
- [ ] No plain `out[0] = out[0] + v` from several workgroups (a race): use atomics.
|
|
145
|
+
- [ ] int64 inputs read as `.x` of `vec2<u32>`.
|
|
146
|
+
- [ ] Workgroup arrays sized by the constant workgroup size, not by a runtime value.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Common porting bugs (all backends)
|
|
2
|
+
|
|
3
|
+
Read this once before your first port. When `verify_port` fails, match the
|
|
4
|
+
failure report against these symptoms first.
|
|
5
|
+
|
|
6
|
+
| Symptom in the report | Likely cause |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `unexpected NaN` at the end of the tensor, odd sizes only | Tail not covered: grid rounded down, or loop stops at `n - n % BLOCK` |
|
|
9
|
+
| `unexpected NaN` everywhere | Port never writes the output (wrote to a copy, returned a new tensor, wrong param name) |
|
|
10
|
+
| `unexpected NaN` in a few rows | 0/0 or inf-inf: missing max-subtraction (softmax), uninitialised accumulator, masked lanes with a non-neutral `other=` |
|
|
11
|
+
| Fails only for rows longer than one block (e.g. cols=4097, not 1024) | Chunk loop missing or reduction only covers the first chunk |
|
|
12
|
+
| Small errors (1e-4 to 1e-2) only when the size is not a multiple of the block (7, 1000, 4097 fail; 1, 512, 1024 pass) | Masked lanes feed a reduction: they load `other=0.0` and then contribute (e.g. `(0 - mean)**2` in a variance). Wrap the term: `tl.where(mask, d * d, 0.0)` |
|
|
13
|
+
| Fails only for tiny sizes (1, 7) | Block size larger than data without a mask, or division by a size that becomes 0 |
|
|
14
|
+
| Fails only for fp16 / bf16 | Accumulating in half precision; missing `.to(float32)` before math |
|
|
15
|
+
| Fails only for float32 with ~1e-3 error | TF32 / fast-math / reduced-precision instructions |
|
|
16
|
+
| Every value off by a constant factor | Wrong normalisation: divide by `n` vs `n-1`, eps inside vs outside sqrt |
|
|
17
|
+
| Values correct but in the wrong place | Transposed indexing: row/col swapped, wrong stride, wrong output layout |
|
|
18
|
+
| Off by exactly one element / shifted | Inclusive vs exclusive scan, off-by-one in offsets |
|
|
19
|
+
| `inout` output wrong | Overwrote instead of updating (`y = a*x` instead of `y = a*x + y`), or ignored the initial value |
|
|
20
|
+
| Integer outputs differ | Wrong rounding/truncation, int32 overflow in index math |
|
|
21
|
+
| Different wrong values on each run, or only at large sizes | Data race: several threads/iterations write one element without an atomic (missing `__syncthreads` / `tl.atomic_add`; on cpu, `out[0] += v` inside `prange`) |
|
|
22
|
+
| `error: TypeError: run() returned a value` (cpu) | Outputs must be written in place; `run` must return None |
|
|
23
|
+
| Crash / illegal address | Unmasked out-of-bounds load, index array value used without bounds |
|
|
24
|
+
|
|
25
|
+
## Checklist before calling verify_port
|
|
26
|
+
|
|
27
|
+
- [ ] Same math as the CUDA original, including its exact formula quirks (where eps goes,
|
|
28
|
+
biased variance, clamping, rounding mode of integer division).
|
|
29
|
+
- [ ] Every element of every output is written exactly once (or accumulated, for inouts/atomics).
|
|
30
|
+
- [ ] Bounds handled for every size in the spec: 1, 7, odd, power of two, large.
|
|
31
|
+
- [ ] Reductions accumulate in float32 for fp16/bf16 inputs.
|
|
32
|
+
- [ ] Index math cannot overflow 32 bits for the large case.
|
|
33
|
+
- [ ] No shortcuts that skip the kernel (calling torch / numpy library ops that do the kernel's work
|
|
34
|
+
instead of writing it for the backend).
|
|
35
|
+
|
|
36
|
+
## When you are stuck
|
|
37
|
+
|
|
38
|
+
After 2 failures with the same symptom, change strategy: simplify the port (one
|
|
39
|
+
element per lane, no tiling), get it to PASS, then optimise and verify again.
|
|
40
|
+
After 6 failed attempts total, stop and report FAIL with your best explanation.
|
kernelport/agent.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Optional built-in agent: drives the kernelport tools with Claude through the Anthropic API.
|
|
2
|
+
|
|
3
|
+
For people who do not use a coding agent. It needs `pip install kernelport[agent]` and the
|
|
4
|
+
user's own Anthropic credentials (ANTHROPIC_API_KEY, or an `ant auth login` profile); every
|
|
5
|
+
run is billed to that account. The agent can only act through the 12 kernelport tools, so
|
|
6
|
+
all writes stay inside the repo's .kernelport/ workspace and it cannot run shell commands.
|
|
7
|
+
|
|
8
|
+
The loop is written by hand (not the SDK's beta tool runner) so the turn budget, the tool
|
|
9
|
+
surface and the client are fully under kernelport's control and testable with a fake client.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import inspect
|
|
15
|
+
import json
|
|
16
|
+
import types
|
|
17
|
+
import typing
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from kernelport import tools as kp_tools
|
|
23
|
+
from kernelport.mcp_server import INSTRUCTIONS
|
|
24
|
+
|
|
25
|
+
DEFAULT_MODEL = "claude-opus-5-5"
|
|
26
|
+
DEFAULT_EFFORT = "high"
|
|
27
|
+
DEFAULT_MAX_TURNS = 80
|
|
28
|
+
MAX_TOKENS = 16000
|
|
29
|
+
MAX_RESULT_CHARS = 60_000
|
|
30
|
+
|
|
31
|
+
SYSTEM_PREAMBLE = """You are kernelport's built-in agent. You port CUDA kernels to other backends and prove
|
|
32
|
+
each port correct, using only the kernelport tools. Follow the workflow skill below exactly.
|
|
33
|
+
Work autonomously: do not ask the user questions; make reasonable choices and state them.
|
|
34
|
+
When finished, reply with the markdown report from make_report(format="markdown") and a short
|
|
35
|
+
summary of anything that is not PASS and why. Never describe a port as working unless
|
|
36
|
+
verify_port returned PASS."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _json_type(annotation: Any) -> dict:
|
|
40
|
+
"""JSON schema for a tool parameter annotation (str, dict, and their Optional forms)."""
|
|
41
|
+
if isinstance(annotation, str): # from __future__ annotations
|
|
42
|
+
annotation = eval(annotation, {"dict": dict, "str": str, "Any": Any}) # noqa: S307 (own source)
|
|
43
|
+
origin = typing.get_origin(annotation)
|
|
44
|
+
if origin in (typing.Union, types.UnionType):
|
|
45
|
+
args = [a for a in typing.get_args(annotation) if a is not type(None)]
|
|
46
|
+
return _json_type(args[0]) if args else {}
|
|
47
|
+
if annotation is dict or origin is dict:
|
|
48
|
+
return {"type": "object"}
|
|
49
|
+
return {"type": "string"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tool_definitions() -> list[dict]:
|
|
53
|
+
"""Anthropic tool definitions for every kernelport tool (deterministic order, no `root`)."""
|
|
54
|
+
defs = []
|
|
55
|
+
for fn in kp_tools.TOOLS:
|
|
56
|
+
props, required = {}, []
|
|
57
|
+
for name, p in inspect.signature(fn).parameters.items():
|
|
58
|
+
if name == "root":
|
|
59
|
+
continue # the workspace is fixed by the agent, never chosen by the model
|
|
60
|
+
props[name] = _json_type(p.annotation)
|
|
61
|
+
if p.default is inspect.Parameter.empty:
|
|
62
|
+
required.append(name)
|
|
63
|
+
doc = inspect.getdoc(fn) or fn.__name__
|
|
64
|
+
defs.append({"name": fn.__name__, "description": doc,
|
|
65
|
+
"input_schema": {"type": "object", "properties": props, "required": required}})
|
|
66
|
+
return defs
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def system_prompt() -> str:
|
|
70
|
+
"""Stable system prompt (cached): preamble + MCP instructions + the workflow skill."""
|
|
71
|
+
skill = kp_tools.read_skill("workflow").get("content", "")
|
|
72
|
+
return f"{SYSTEM_PREAMBLE}\n\n{INSTRUCTIONS}\n\n<workflow_skill>\n{skill}\n</workflow_skill>"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_tool(name: str, args: dict, root: Path) -> tuple[str, bool, str]:
|
|
76
|
+
"""Execute one tool call. Returns (result JSON text, is_error, one-line summary)."""
|
|
77
|
+
fns = {fn.__name__: fn for fn in kp_tools.TOOLS}
|
|
78
|
+
if name not in fns:
|
|
79
|
+
return json.dumps({"error": f"unknown tool {name}"}), True, f"unknown tool {name}"
|
|
80
|
+
fn = fns[name]
|
|
81
|
+
accepted = set(inspect.signature(fn).parameters) - {"root"}
|
|
82
|
+
bad = set(args) - accepted
|
|
83
|
+
if bad:
|
|
84
|
+
msg = f"unknown arguments {sorted(bad)} for {name}; accepted: {sorted(accepted)}"
|
|
85
|
+
return json.dumps({"error": msg}), True, msg
|
|
86
|
+
kwargs = dict(args)
|
|
87
|
+
if "root" in inspect.signature(fn).parameters:
|
|
88
|
+
kwargs["root"] = str(root)
|
|
89
|
+
try:
|
|
90
|
+
result = fn(**kwargs)
|
|
91
|
+
except Exception as exc: # a tool bug must not kill the loop; the model sees the error
|
|
92
|
+
msg = f"{type(exc).__name__}: {exc}"
|
|
93
|
+
return json.dumps({"error": msg}), True, msg
|
|
94
|
+
text = json.dumps(result, default=str)
|
|
95
|
+
if len(text) > MAX_RESULT_CHARS:
|
|
96
|
+
text = text[:MAX_RESULT_CHARS] + ' ...[truncated]"}'
|
|
97
|
+
return text, not result.get("ok", True) and "error" in result, str(result.get("summary", ""))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class AgentResult:
|
|
102
|
+
"""Outcome of one agent run."""
|
|
103
|
+
|
|
104
|
+
final_text: str
|
|
105
|
+
stop_reason: str
|
|
106
|
+
turns: int
|
|
107
|
+
tool_calls: int
|
|
108
|
+
input_tokens: int = 0
|
|
109
|
+
output_tokens: int = 0
|
|
110
|
+
cache_read_tokens: int = 0
|
|
111
|
+
log: list[str] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def make_client():
|
|
115
|
+
"""The real Anthropic client (imported only here, so kernelport works without the SDK)."""
|
|
116
|
+
try:
|
|
117
|
+
import anthropic
|
|
118
|
+
except ImportError as exc:
|
|
119
|
+
raise RuntimeError("the agent needs the Anthropic SDK: pip install kernelport[agent]") from exc
|
|
120
|
+
return anthropic.Anthropic()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def run_agent(task: str, root: Path, *, client=None, model: str = DEFAULT_MODEL,
|
|
124
|
+
effort: str = DEFAULT_EFFORT, max_turns: int = DEFAULT_MAX_TURNS,
|
|
125
|
+
fallbacks: bool = True, on_event: Callable[[str], None] = print) -> AgentResult:
|
|
126
|
+
"""Run the tool-use loop until Claude finishes, refuses, or the turn budget is spent."""
|
|
127
|
+
client = client or make_client()
|
|
128
|
+
tools = tool_definitions()
|
|
129
|
+
system = [{"type": "text", "text": system_prompt(), "cache_control": {"type": "ephemeral"}}]
|
|
130
|
+
messages: list[dict] = [{"role": "user", "content": task}]
|
|
131
|
+
res = AgentResult(final_text="", stop_reason="", turns=0, tool_calls=0)
|
|
132
|
+
|
|
133
|
+
def emit(line: str) -> None:
|
|
134
|
+
res.log.append(line)
|
|
135
|
+
on_event(line)
|
|
136
|
+
|
|
137
|
+
extra: dict[str, Any] = {}
|
|
138
|
+
if fallbacks: # on a safety decline, the API retries on a fallback model in the same call
|
|
139
|
+
extra = {"betas": ["server-side-fallback-2026-07-01"], "fallbacks": "default"}
|
|
140
|
+
|
|
141
|
+
while res.turns < max_turns:
|
|
142
|
+
res.turns += 1
|
|
143
|
+
response = client.beta.messages.create(
|
|
144
|
+
model=model, max_tokens=MAX_TOKENS, system=system, tools=tools, messages=messages,
|
|
145
|
+
output_config={"effort": effort}, cache_control={"type": "ephemeral"}, **extra)
|
|
146
|
+
usage = getattr(response, "usage", None)
|
|
147
|
+
if usage is not None:
|
|
148
|
+
res.input_tokens += getattr(usage, "input_tokens", 0) or 0
|
|
149
|
+
res.output_tokens += getattr(usage, "output_tokens", 0) or 0
|
|
150
|
+
res.cache_read_tokens += getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
151
|
+
res.stop_reason = response.stop_reason
|
|
152
|
+
for block in response.content:
|
|
153
|
+
if block.type == "text" and block.text.strip():
|
|
154
|
+
res.final_text = block.text
|
|
155
|
+
emit(block.text)
|
|
156
|
+
messages.append({"role": "assistant", "content": response.content})
|
|
157
|
+
|
|
158
|
+
if response.stop_reason == "refusal":
|
|
159
|
+
emit("Claude declined to continue this request (stop_reason: refusal).")
|
|
160
|
+
return res
|
|
161
|
+
if response.stop_reason == "max_tokens":
|
|
162
|
+
emit("response hit max_tokens; stopping.")
|
|
163
|
+
return res
|
|
164
|
+
calls = [b for b in response.content if b.type == "tool_use"]
|
|
165
|
+
if response.stop_reason != "tool_use" or not calls:
|
|
166
|
+
return res
|
|
167
|
+
results = []
|
|
168
|
+
for call in calls:
|
|
169
|
+
res.tool_calls += 1
|
|
170
|
+
text, is_error, summary = run_tool(call.name, dict(call.input or {}), root)
|
|
171
|
+
emit(f" [{call.name}] {summary}")
|
|
172
|
+
results.append({"type": "tool_result", "tool_use_id": call.id, "content": text,
|
|
173
|
+
**({"is_error": True} if is_error else {})})
|
|
174
|
+
messages.append({"role": "user", "content": results}) # all results in one message
|
|
175
|
+
|
|
176
|
+
res.stop_reason = "max_turns"
|
|
177
|
+
emit(f"turn budget of {max_turns} used up; stopping. Run `kernelport status` to see where it got to.")
|
|
178
|
+
return res
|
|
File without changes
|