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.
Files changed (72) hide show
  1. kernelport/__init__.py +3 -0
  2. kernelport/__main__.py +5 -0
  3. kernelport/_skills/kernelport/SKILL.md +200 -0
  4. kernelport/_skills/kernelport/backends/cpu.md +138 -0
  5. kernelport/_skills/kernelport/backends/hip.md +53 -0
  6. kernelport/_skills/kernelport/backends/metal.md +76 -0
  7. kernelport/_skills/kernelport/backends/triton.md +117 -0
  8. kernelport/_skills/kernelport/backends/webgpu.md +146 -0
  9. kernelport/_skills/kernelport/common_bugs.md +40 -0
  10. kernelport/agent.py +178 -0
  11. kernelport/analyzer/__init__.py +0 -0
  12. kernelport/analyzer/cuda_parser.py +480 -0
  13. kernelport/analyzer/models.py +80 -0
  14. kernelport/analyzer/suggest.py +120 -0
  15. kernelport/backends/__init__.py +5 -0
  16. kernelport/backends/base.py +138 -0
  17. kernelport/backends/cpu/__init__.py +1 -0
  18. kernelport/backends/cpu/capability.py +25 -0
  19. kernelport/backends/cpu/config.py +36 -0
  20. kernelport/backends/cpu/runner.py +67 -0
  21. kernelport/backends/hip/__init__.py +1 -0
  22. kernelport/backends/hip/capability.py +34 -0
  23. kernelport/backends/hip/config.py +28 -0
  24. kernelport/backends/hip/runner.py +48 -0
  25. kernelport/backends/metal/__init__.py +1 -0
  26. kernelport/backends/metal/capability.py +32 -0
  27. kernelport/backends/metal/config.py +35 -0
  28. kernelport/backends/metal/runner.py +142 -0
  29. kernelport/backends/triton/__init__.py +1 -0
  30. kernelport/backends/triton/capability.py +29 -0
  31. kernelport/backends/triton/config.py +41 -0
  32. kernelport/backends/triton/runner.py +44 -0
  33. kernelport/backends/webgpu/__init__.py +1 -0
  34. kernelport/backends/webgpu/capability.py +34 -0
  35. kernelport/backends/webgpu/config.py +38 -0
  36. kernelport/backends/webgpu/runner.py +285 -0
  37. kernelport/bench/__init__.py +0 -0
  38. kernelport/bench/timer.py +76 -0
  39. kernelport/cli.py +501 -0
  40. kernelport/doctor.py +285 -0
  41. kernelport/hardware.py +60 -0
  42. kernelport/harness/__init__.py +0 -0
  43. kernelport/harness/compare.py +152 -0
  44. kernelport/harness/device_source.py +273 -0
  45. kernelport/harness/expr.py +80 -0
  46. kernelport/harness/inputs.py +194 -0
  47. kernelport/harness/reference.py +76 -0
  48. kernelport/harness/results.py +118 -0
  49. kernelport/harness/run_cuda.py +126 -0
  50. kernelport/harness/spec.py +199 -0
  51. kernelport/harness/tolerances.py +48 -0
  52. kernelport/harness/verify.py +131 -0
  53. kernelport/hashing.py +28 -0
  54. kernelport/mcp_server.py +95 -0
  55. kernelport/report/__init__.py +1 -0
  56. kernelport/report/data.py +147 -0
  57. kernelport/report/html.py +174 -0
  58. kernelport/report/markdown.py +75 -0
  59. kernelport/setup_agents/__init__.py +1 -0
  60. kernelport/setup_agents/backup.py +59 -0
  61. kernelport/setup_agents/detect.py +78 -0
  62. kernelport/setup_agents/register.py +302 -0
  63. kernelport/setup_agents/skills_install.py +31 -0
  64. kernelport/tools.py +455 -0
  65. kernelport/tune.py +99 -0
  66. kernelport/workspace.py +400 -0
  67. kernelport/zoo.py +176 -0
  68. kernelport-0.1.0.dist-info/METADATA +228 -0
  69. kernelport-0.1.0.dist-info/RECORD +72 -0
  70. kernelport-0.1.0.dist-info/WHEEL +4 -0
  71. kernelport-0.1.0.dist-info/entry_points.txt +2 -0
  72. 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