This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-performance-tests.git

commit 935c9e502d98e56ecfceb509e787265822ad0a5b
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 15 09:42:19 2026 +0200

    ai-benchmark: the harness of the local-model benchmark series against the 
Camel MCP server
    
    One-shot and stepwise benchmarks scored by what runs, the example and step 
definitions, the MCP client, the
    suite and server scripts, the results of the 2026-09 series, and a README 
for people and agents who want to
    repeat it with another model or another set of examples. Blog:
    https://camel.apache.org/blog/2026/09/camel-local-model-benchmark/
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj
---
 ai-benchmark/.gitignore                            |   7 +
 ai-benchmark/README.md                             | 107 ++++++++++
 ai-benchmark/agent_local.py                        | 186 +++++++++++++++++
 ai-benchmark/agent_mcp_stepwise.py                 | 231 +++++++++++++++++++++
 ai-benchmark/examples.json                         |  15 ++
 ai-benchmark/gen_local.py                          | 103 +++++++++
 ai-benchmark/mcp_client.py                         |  76 +++++++
 ai-benchmark/results-2026-09.md                    |  31 +++
 ai-benchmark/run-suite.sh                          |  23 ++
 ai-benchmark/run_one.sh                            |  44 ++++
 ai-benchmark/start-server.sh                       |  14 ++
 ai-benchmark/steps.json                            | 117 +++++++++++
 .../stepwise-project/application.properties        |   4 +
 ai-benchmark/stepwise-project/timer-log.camel.yaml |  15 ++
 ai-benchmark/summarize_runs.py                     |  78 +++++++
 15 files changed, 1051 insertions(+)

diff --git a/ai-benchmark/.gitignore b/ai-benchmark/.gitignore
new file mode 100644
index 0000000..285a577
--- /dev/null
+++ b/ai-benchmark/.gitignore
@@ -0,0 +1,7 @@
+# run outputs
+oneshot-*/
+stepwise/
+local/
+*.log
+*.out
+__pycache__/
diff --git a/ai-benchmark/README.md b/ai-benchmark/README.md
new file mode 100644
index 0000000..d0eeae6
--- /dev/null
+++ b/ai-benchmark/README.md
@@ -0,0 +1,107 @@
+# AI benchmark: a local model builds and edits Camel integrations through the 
Camel MCP server
+
+A small harness that measures how well a model builds and edits Apache Camel 
integrations when its only Camel
+knowledge is what the Camel MCP server (`camel mcp`) gives it: the catalog, 
validation, and a `camel run` loop.
+It was written for the series described in the blog post
+[We had a frontier AI coach a small local model through 
Camel](https://camel.apache.org/blog/2026/09/camel-local-model-benchmark/)
+(2026-09-15), where a 22 GB local model went from 0 to 12 of 13 beginner 
examples over twenty runs while Camel, not
+the model, was changed between runs. The results of that series are in 
[results-2026-09.md](results-2026-09.md).
+
+The harness is here so the next series can be run the same way with a 
different set of examples, a different model,
+or a later Camel, and so anyone, human or agent, can repeat it.
+
+## What it measures
+
+Two benchmarks, both scored by what actually runs, not by reading the model's 
output:
+
+1. **One-shot** (`agent_local.py`): each example is requested with its 
one-line description only ("Split a batch of
+   items into individual messages for processing"). The model may call the 
catalog and validation tools, then answers
+   with complete files. The harness writes them, validates them with the Camel 
CLI, runs them with `camel run` for a few
+   seconds, and checks the log for the described behaviour. A failure is fed 
back (validator output, run errors) for up
+   to three rounds. Pass means: validates, starts, and shows the expected 
activity in the log.
+2. **Stepwise** (`agent_mcp_stepwise.py`): the model edits a running 
integration one request at a time ("fire every
+   five seconds", "add a choice", "add error handling", eight requests) 
through the write, run, log and error tools of
+   the MCP server. Each step is scored on the file on disk (a regex), the 
properties file, the log of the running
+   integration after the reload, and the errors, against a reference 
checkpoint.
+
+The model is never shown the example projects or their catalog tools; the 
one-shot tool set is restricted to catalog
+lookups and validation (`BENCH_TOOL_ALLOW` in `run-suite.sh`).
+
+## Prerequisites
+
+- Java 17+ and the Camel CLI (`camel`) on the PATH, the version you want to 
measure (a locally built snapshot works).
+- [Ollama](https://ollama.com) with the model pulled, e.g. `ollama pull 
qwen3.6:35b-a3b` (any model that supports tool
+  calling in Ollama's chat API works; set `BENCH_MODEL`).
+- Python 3.10+ (standard library only, no packages).
+- macOS or Linux; the scripts are zsh (`run_one.sh`, `start-server.sh`, 
`run-suite.sh`).
+
+## Running a suite
+
+```bash
+cd ai-benchmark
+./start-server.sh            # camel mcp --http on port 9090, waits until it 
answers
+./run-suite.sh my-run        # one-shot examples, then the stepwise edits; 
about 30 to 60 minutes on a laptop
+python3 summarize_runs.py my-run   # the markdown table for that run 
(run-suite.sh prints it too)
+```
+
+To compare runs, pass several tags: `python3 summarize_runs.py before after1 
after2`.
+
+Environment variables (all optional):
+
+| Variable | Default | Meaning |
+|---|---|---|
+| `BENCH_MODEL` | `qwen3.6:35b-a3b` | the Ollama model |
+| `OLLAMA_HOST` | `http://localhost:11434` | where Ollama listens |
+| `MCP_URL` | `http://127.0.0.1:9090/mcp` | the Camel MCP server (Streamable 
HTTP) |
+| `BENCH_ROUNDS` | `3` | one-shot rounds per example |
+| `BENCH_TOOL_CALLS` | `10` / `12` | tool calls per round (one-shot) / per 
step (stepwise) |
+| `BENCH_TOOL_ALLOW` | see `run-suite.sh` | regex of the tools offered in the 
one-shot benchmark |
+| `BENCH_OUT` | `oneshot` | one-shot output directory (`run-suite.sh` sets 
`oneshot-<tag>`) |
+| `BENCH_TAG` | model name | stepwise output directory under `stepwise/` 
(`run-suite.sh` sets the tag) |
+| `BENCH_STEPS` | `steps.json` | the stepwise scenario |
+| `BENCH_VALIDATE_PROPS`, `BENCH_VALIDATE_SOURCE` | `0` | also run `camel 
validate properties` and `camel validate source` on the written files 
(`run-suite.sh` sets both) |
+| `CAMEL_MCP_JAR` | unset | run a locally built `camel-jbang-mcp` runner jar 
instead of the CLI plugin |
+
+## What comes out
+
+- `oneshot-<tag>/<example>/attempt<n>/`: the files the model wrote, 
`validate.log`, `run.log`, `probe.log`.
+- `oneshot-<tag>/<example>/trace.jsonl`: every tool call and answer with 
timings and token counts; `result.json`: pass,
+  rounds, tool calls, seconds, tokens.
+- `stepwise/<tag>/step<n>.trace.jsonl`, `step<n>.after.yaml`, `results.json`.
+- `<tag>.log`: the wall clock of the suite.
+
+The useful part is the traces. After a run, read every failed attempt: what 
the model wrote, what the validator said,
+what the runtime said. Every message that told the model what was wrong 
without saying what to write is a Camel
+improvement waiting to be made, for a person as much as for the model; that is 
how the 117 findings of the first series
+were found (about 15 minutes of reading per run).
+
+## Changing the examples
+
+- **One-shot:** `examples.json` is a list of `{name, prompt, expect, 
run_seconds, probe?}`. `prompt` is what the
+  model gets, `expect` is for the human reading the results, `run_seconds` how 
long `camel run` runs, `probe` an
+  optional shell command run 7 s after start (for example a `curl` against a 
REST example) whose output lands in
+  `probe.log`. The pass check looks for log activity from the route; adjust 
`agent_local.py` if an example needs a
+  specific check. The first series used the 13 beginner examples of
+  [camel-jbang-examples](https://github.com/apache/camel-jbang-examples); a 
second series should use examples the
+  model has not seen, such as the intermediate ones.
+- **Stepwise:** `steps.json` names the project directory (`stepwise-project/`, 
reset to the timer-log example before
+  each run), the route and properties files, and the steps, each with a 
`request`, a `check` (`file_regex`,
+  `props_regex`, `log_regex`, `min_log`, `interval_min`, `max_errors`) and a 
`reference` of the expected files.
+
+## Other conditions
+
+- `gen_local.py`: the bare-prompt condition, no tools at all, output under 
`local/`. It is what the first series
+  measured as "0 of 13" and is the baseline any tool-assisted run should be 
compared with.
+- The frontier condition of the first series was not scripted: a coding agent 
with the Camel CLI as tools built the same
+  examples and scored 13 of 13, with the loop turning 9 first-time passes into 
13.
+
+## Notes for an agent running this
+
+- Do not run Maven and the suite at the same time, and do not rebuild 
`camel-jbang-mcp` while a run is in progress:
+  the server loads its jar lazily and a replaced jar breaks it mid-run. 
Restart the server after a rebuild.
+- A `camel.main.durationMaxSeconds` in a generated `application.properties` 
overrides `--max-seconds`; `run_one.sh` has
+  a watchdog for that.
+- A model call that takes more than ten minutes is the machine asleep, not the 
model; `summarize_runs.py` subtracts
+  those, and `run-suite.sh` runs `caffeinate` on macOS.
+- Keep the examples away from the model: never offer `camel_catalog_examples` 
or `camel_catalog_example_file` in
+  `BENCH_TOOL_ALLOW` for a benchmark that uses the examples repository.
diff --git a/ai-benchmark/agent_local.py b/ai-benchmark/agent_local.py
new file mode 100755
index 0000000..c804329
--- /dev/null
+++ b/ai-benchmark/agent_local.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+"""One-shot benchmark: a local model builds each example from its one-line 
description, with the Camel MCP
+server's catalog and validation tools, in a small agent loop.
+
+Per example:
+  round 1..MAX_ROUNDS:
+    model may call MCP tools (catalog lookups, YAML validation) up to 
MAX_TOOL_CALLS times,
+    then answers with files in '=== FILE: name ===' format.
+    We write the files to <BENCH_OUT>/<name>/attempt<round>/, run validate + 
camel run, and if it
+    fails we feed the validator/run errors back and loop.
+Everything (tool calls, timings, tokens) is logged to 
<BENCH_OUT>/<name>/trace.jsonl.
+"""
+import json, os, re, subprocess, sys, time, urllib.request
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from mcp_client import McpClient  # noqa: E402
+from gen_local import write_files, strip_think  # noqa: E402
+
+MODEL = os.environ.get("BENCH_MODEL", "qwen3.6:35b-a3b")
+HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434";)
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(HERE, os.environ.get("BENCH_OUT", "oneshot"))
+MAX_ROUNDS = int(os.environ.get("BENCH_ROUNDS", "3"))
+MAX_TOOL_CALLS = int(os.environ.get("BENCH_TOOL_CALLS", "10"))
+TOOL_RESULT_CAP = 6000
+
+# Tools offered to the model: catalog lookups and validation only. No example 
catalog
+# (that would hand the model the answer), no runtime, security, migration or 
dependency tools.
+ALLOW = re.compile(os.environ.get("BENCH_TOOL_ALLOW",
+    
r"^camel_(catalog_(components|component_doc|eips|eip_doc|languages|language_doc|dataformats|dataformat_doc|doc_pages|doc_page)"
+    
r"|validate_(yaml|endpoint|route|configuration)|component_(doc|properties)|eip_doc|language_doc|dataformat_doc)"))
+
+SYSTEM = """You are an AI assistant helping a developer build a small Apache 
Camel integration that runs
+with the Camel CLI (camel-jbang), written in Camel YAML DSL.
+
+Facts about the Camel YAML DSL you must respect:
+- A route file is a top-level YAML LIST. Each entry is one of `- route:`, `- 
from:`, `- beans:`, `- rest:`, `- restConfiguration:`.
+- A route has `from:` with `uri:` and `steps:`; each step is a map with one 
EIP key such as `log:`, `to:`, `setBody:`, `choice:`, `split:`, `aggregate:`, 
`filter:`, `circuitBreaker:`, `bean:`.
+- Expressions are written as `simple: "..."`, `constant: "..."`, `groovy: 
"..."`, `tokenize: {token: ","}`.
+- Java beans must be declared with `- beans:` using `type: 
"#class:<FullyQualifiedOrSimpleClassName>"` before `bean: {ref: name}`.
+- Use a timer or a file consumer as the trigger so the example produces output 
on its own.
+
+Use the tools to look up component options, EIP options, a validated YAML 
sample of an EIP you have not used before (camel_catalog_sample), and to 
validate your YAML before you answer. Then answer with only the
+files, each in this exact format, and nothing else:
+
+=== FILE: <filename> ===
+<content>
+
+Route files use the extension .camel.yaml. Add application.properties, Java 
beans, XSLT or input files when needed."""
+
+
+def ollama_chat(messages, tools):
+    body = json.dumps({"model": MODEL, "messages": messages, "tools": tools, 
"stream": False,
+                       "options": {"temperature": 0.2, "num_ctx": 
32768}}).encode()
+    req = urllib.request.Request(HOST + "/api/chat", data=body, 
headers={"Content-Type": "application/json"})
+    t0 = time.time()
+    with urllib.request.urlopen(req, timeout=1800) as r:
+        data = json.load(r)
+    return data, time.time() - t0
+
+
+def to_ollama_tools(mcp_tools):
+    out = []
+    for t in mcp_tools:
+        if not ALLOW.match(t["name"]):
+            continue
+        schema = t.get("inputSchema") or {"type": "object", "properties": {}}
+        out.append({"type": "function", "function": {
+            "name": t["name"], "description": (t.get("description") or 
"")[:300],
+            "parameters": schema}})
+    return out
+
+
+def run_folder(folder, secs, probe):
+    subprocess.run([os.path.join(HERE, "run_one.sh"), folder, str(secs), probe 
or ""], check=False)
+    v = open(os.path.join(folder, "validate.log")).read()
+    r = open(os.path.join(folder, "run.log")).read()
+    p = open(os.path.join(folder, "probe.log")).read() if 
os.path.exists(os.path.join(folder, "probe.log")) else ""
+    bad_validate = "exit=1" in v or "Validation error" in v or "no yaml files" 
in v
+    m = re.search(r"Routes startup \(total:(\d+)", r)
+    nroutes = int(m.group(1)) if m else 0
+    # run 16 on: an ERROR line written by the route's own log step (logger = 
the route file) is the route's
+    # message, not a failure (run 15 groovy logged its rejections at ERROR); a 
Camel error, an exception or a
+    # failed delivery still counts
+    errs = [l for l in r.splitlines()
+            if re.search(r"ERROR|Exception|Caused 
by|Unsupported|Unknown|Failed|No bean", l)
+            and not (re.search(r"\.(yaml|java):\d+\s", l) and not 
re.search(r"Exception|Caused by|Failed delivery", l))]
+    activity = len(re.findall(r"\.yaml:\d+ |\.java:\d+ ", r)) > 0 or 
bool(p.strip())
+    if not activity:
+        # run 17 on: a log step with its own logName (priority-logger) logs 
under that name, not the route file;
+        # any log line from a logger that is not Camel's own counts as route 
activity
+        for l in r.splitlines():
+            m = re.search(r" (INFO|WARN|ERROR|DEBUG) +\d+ --- \[[^\]]*\] (\S+) 
+: ", l)
+            if m and not re.search(r"camel|Camel|MainSupport|Shutdown", 
m.group(2)):
+                activity = True
+                break
+    # run 14 on: a route that logs from its own file has loaded, even when 
logging.level.root=WARN (honoured since
+    # CAMEL-24701) hides the "Routes startup" line the count is read from
+    if nroutes == 0 and activity:
+        nroutes = 1
+    ok = (not bad_validate) and nroutes > 0 and not errs and activity
+    return ok, v, "\n".join(errs[:15]), nroutes, activity
+
+
+def main():
+    examples = json.load(open(os.path.join(HERE, "examples.json")))
+    only = sys.argv[1:]
+    mcp = McpClient(); mcp.initialize(); tools = 
to_ollama_tools(mcp.list_tools())
+    log = open(os.path.join(HERE, "agent_local.log"), "a")
+    print(f"tools offered: {[t['function']['name'] for t in tools]}", 
file=log, flush=True)
+    for ex in examples:
+        n = ex["name"]
+        if only and n not in only:
+            continue
+        base = os.path.join(OUT, n)
+        if os.path.exists(os.path.join(base, "result.json")):
+            continue
+        os.makedirs(base, exist_ok=True)
+        trace = open(os.path.join(base, "trace.jsonl"), "w")
+        messages = [{"role": "system", "content": SYSTEM},
+                    {"role": "user", "content": f"Create a runnable Camel CLI 
example: {ex['prompt']}."}]
+        result = {"name": n, "rounds": 0, "tool_calls": 0, "ok": False, 
"seconds": 0, "tokens": 0}
+        t_start = time.time()
+        for rnd in range(1, MAX_ROUNDS + 1):
+            result["rounds"] = rnd
+            calls = 0
+            text = ""
+            while True:
+                try:
+                    data, secs = ollama_chat(messages, tools)
+                except Exception as e:
+                    text = ""; trace.write(json.dumps({"round": rnd, "error": 
str(e)}) + "\n"); break
+                msg = data["message"]; result["tokens"] += 
data.get("eval_count", 0)
+                trace.write(json.dumps({"round": rnd, "secs": round(secs, 1), 
"eval": data.get("eval_count"),
+                                        "prompt_eval": 
data.get("prompt_eval_count"),
+                                        "tool_calls": msg.get("tool_calls"), 
"content": (msg.get("content") or "")[:400]}) + "\n")
+                trace.flush()
+                messages.append({"role": "assistant", "content": 
msg.get("content") or "", "tool_calls": msg.get("tool_calls")})
+                if msg.get("tool_calls") and calls < MAX_TOOL_CALLS:
+                    for tc in msg["tool_calls"]:
+                        fn = tc["function"]; calls += 1; result["tool_calls"] 
+= 1
+                        args = fn.get("arguments") or {}
+                        if isinstance(args, str):
+                            try:
+                                args = json.loads(args)
+                            except Exception:
+                                args = {}
+                        try:
+                            out = mcp.call(fn["name"], args)
+                        except Exception as e:
+                            out = "ERROR: " + str(e)
+                        out = out[:TOOL_RESULT_CAP]
+                        trace.write(json.dumps({"round": rnd, "tool": 
fn["name"], "args": args, "result": out[:600]}) + "\n")
+                        trace.flush()
+                        messages.append({"role": "tool", "content": out, 
"tool_name": fn["name"]})
+                    continue
+                text = msg.get("content") or ""
+                break
+            folder = os.path.join(base, f"attempt{rnd}")
+            files = write_files(text, folder)
+            with open(os.path.join(folder, "raw.txt"), "w") as f:
+                f.write(text)
+            ok, v, errs, nroutes, activity = run_folder(folder, 
ex["run_seconds"], ex.get("probe"))
+            print(f"{n}: round{rnd} calls={calls} files={files} ok={ok} 
routes={nroutes} activity={activity}", file=log, flush=True)
+            if ok:
+                result["ok"] = True
+                break
+            fb = "I saved and ran your files with the Camel CLI.\n\n`camel 
validate yaml` output:\n" + (v.strip() or "(passed)")
+            if errs:
+                fb += "\n\nErrors from `camel run`:\n" + errs
+            if nroutes == 0 and not errs:
+                fb += "\n\nThe application started but loaded 0 routes."
+            if nroutes > 0 and not errs and not activity:
+                fb += (f"\n\nThe route loaded but produced no log output in 
{ex['run_seconds']} seconds; it must produce "
+                       "output on its own (timer trigger, or create the input 
files it reads).")
+            fb += "\n\nUse the tools to check the options you are unsure about 
and validate the YAML, then output the complete corrected files again in the 
same '=== FILE: <name> ===' format."
+            messages.append({"role": "user", "content": fb})
+        result["seconds"] = round(time.time() - t_start, 1)
+        json.dump(result, open(os.path.join(base, "result.json"), "w"))
+        json.dump(messages, open(os.path.join(base, "messages.json"), "w"))
+        trace.close()
+        print(f"{n}: DONE ok={result['ok']} rounds={result['rounds']} 
tool_calls={result['tool_calls']} secs={result['seconds']} 
tokens={result['tokens']}", file=log, flush=True)
+    log.close()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/ai-benchmark/agent_mcp_stepwise.py 
b/ai-benchmark/agent_mcp_stepwise.py
new file mode 100755
index 0000000..8d20cc9
--- /dev/null
+++ b/ai-benchmark/agent_mcp_stepwise.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+"""Stepwise editing benchmark against camel-jbang-mcp (camel mcp --http), 
using the shared authoring tools
+from CAMEL-24695.
+
+The model gets the shared authoring set (camel_catalog_doc, 
camel_catalog_find, camel_validate_source,
+camel_get_files, camel_write_file, camel_run, camel_control, camel_get_log, 
camel_get_errors,
+camel_eval_expression, camel_error_diagnose) plus the structured catalog 
tools, and a short neutral system
+prompt. The harness starts the integration once with camel_run (dev mode) 
before step 1, then sends the
+8 requests one at a time. Scoring: files on disk, log via camel_get_log,
+errors via camel_get_errors, diff size, reference checkpoint after each step.
+
+Env: BENCH_MODEL (ollama model), MCP_URL (default http://127.0.0.1:9090/mcp), 
BENCH_TAG (results folder name),
+BENCH_STEPS (the steps file, default steps.json),
+BENCH_TOOL_CALLS (max tool calls per step, default 12).
+"""
+import difflib, json, os, re, sys, time, urllib.request
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from mcp_client import McpClient  # noqa: E402
+
+MODEL = os.environ.get("BENCH_MODEL", "qwen3.6:35b-a3b")
+HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434";)
+MCP_URL = os.environ.get("MCP_URL", "http://127.0.0.1:9090/mcp";)
+HERE = os.path.dirname(os.path.abspath(__file__))
+TAG = os.environ.get("BENCH_TAG", "mcp-" + MODEL.replace(":", 
"_").replace("/", "_"))
+OUT = os.path.join(HERE, "stepwise", TAG)
+MAX_TOOL_CALLS = int(os.environ.get("BENCH_TOOL_CALLS", "12"))
+TOOL_RESULT_CAP = 6000
+
+SHARED = ["camel_catalog_doc", "camel_catalog_find", "camel_catalog_sample", 
"camel_validate_source", "camel_get_files", "camel_write_file",
+          "camel_run", "camel_control", "camel_get_log", "camel_get_errors", 
"camel_eval_expression", "camel_error_diagnose"]
+EXTRA = ["camel_catalog_components", "camel_catalog_component_doc", 
"camel_catalog_eips", "camel_catalog_eip_doc",
+         "camel_catalog_languages", "camel_catalog_language_doc", 
"camel_catalog_dataformats", "camel_catalog_dataformat_doc"]
+
+SYSTEM = (
+    "You are an Apache Camel assistant helping a developer edit a running 
Camel integration through the Camel MCP server.\n\n"
+    "The project directory is {directory}. The integration {name} is already 
running from it in dev mode: files you write are "
+    "reloaded automatically.\n\n"
+    "Guidelines:\n"
+    "- To edit: camel_get_files (directory, optionally file) to read, then 
camel_write_file (directory, file, content) with the "
+    "complete file. Invalid YAML or properties is refused with errors: fix 
them (camel_catalog_doc has the option names) and write again.\n"
+    "- camel_validate_source checks content before writing; 
camel_eval_expression checks a simple expression; camel_get_log and "
+    "camel_get_errors show what the running integration did after a reload.\n"
+    "- camel_catalog_sample (name) shows a validated YAML sample of an EIP and 
where it goes (top level or step): use it "
+    "before writing an EIP you have not written in this file yet, and after a 
'not defined in the schema' error.\n"
+    "- Simple: functions inside ${...}, operators between them: ${header.a} == 
'b', ${body} ?: 'none'.\n"
+    "- Never stop, kill or restart the integration unless asked.\n"
+    "- If a tool call returns an error, do not repeat it with the same 
arguments; say what failed and what to try.\n"
+    "- Be concise; when done, say in one or two sentences what you changed.\n"
+)
+
+
+def ollama_chat(messages, tools):
+    body = json.dumps({"model": MODEL, "messages": messages, "tools": tools, 
"stream": False,
+                       "options": {"temperature": 0.2, "num_ctx": 
32768}}).encode()
+    req = urllib.request.Request(HOST + "/api/chat", data=body, 
headers={"Content-Type": "application/json"})
+    t0 = time.time()
+    with urllib.request.urlopen(req, timeout=1800) as r:
+        data = json.load(r)
+    return data, time.time() - t0
+
+
+def to_ollama_tools(mcp_tools, names):
+    byname = {t["name"]: t for t in mcp_tools}
+    out = []
+    for n in names:
+        t = byname.get(n)
+        if not t:
+            print(f"WARNING: tool {n} not exposed by the server", 
file=sys.stderr)
+            continue
+        out.append({"type": "function", "function": {"name": n, "description": 
t.get("description") or "",
+                                                      "parameters": 
t.get("inputSchema") or {"type": "object", "properties": {}}}})
+    return out
+
+
+def read(path):
+    try:
+        return open(path).read()
+    except FileNotFoundError:
+        return ""
+
+
+def jcall(mcp, name, args):
+    out = mcp.call(name, args)
+    try:
+        return json.loads(out), out
+    except Exception:
+        return None, out
+
+
+def log_lines(mcp, name, limit=40):
+    data, raw = jcall(mcp, "camel_get_log", {"name": name, "limit": limit})
+    if isinstance(data, dict):
+        for k in ("lines", "records", "entries"):
+            if k in data:
+                return data[k]
+    if isinstance(data, list):
+        return data
+    return []
+
+
+def score(step, project, cfg, mcp, name, before):
+    chk = step["check"]
+    route = read(os.path.join(project, cfg["route_file"])); props = 
read(os.path.join(project, cfg["props_file"]))
+    result = {"file_ok": True, "props_ok": True, "log_ok": True, "errors": 0}
+    if chk.get("file_regex") and not re.search(chk["file_regex"], route):
+        result["file_ok"] = False
+    if chk.get("file_regex2") and not re.search(chk["file_regex2"], route):
+        result["file_ok"] = False
+    if chk.get("file_not_regex") and re.search(chk["file_not_regex"], route):
+        result["file_ok"] = False
+    if chk.get("props_regex") and not re.search(chk["props_regex"], props):
+        result["props_ok"] = False
+    time.sleep(cfg["wait_seconds"])
+    lines = log_lines(mcp, name, 40)
+    def lvl(l): return (l.get("level") or "").upper()
+    def msg(l): return l.get("message") or l.get("msg") or ""
+    recent = [l for l in lines if isinstance(l, dict) and lvl(l) == "INFO"]
+    msgs = [msg(l) for l in recent]
+    matched = [m for m in msgs if re.search(chk["log_regex"], m)]
+    if len(matched) < chk.get("min_log", 1):
+        result["log_ok"] = False
+    if chk.get("interval_min") and len(matched) >= 2:
+        ts = [l.get("time") or l.get("timestamp") or "" for l in recent if 
re.search(chk["log_regex"], msg(l))][:2]
+        try:
+            def sec(t):
+                t = t.split("T")[-1].split(" ")[-1]; h, m, s = 
t.split(":")[:3]; return int(h) * 3600 + int(m) * 60 + float(s)
+            if abs(sec(ts[0]) - sec(ts[1])) < chk["interval_min"]:
+                result["log_ok"] = False
+        except Exception:
+            pass
+    result["errors"] = sum(1 for l in lines if isinstance(l, dict) and lvl(l) 
== "ERROR")
+    data, _ = jcall(mcp, "camel_get_errors", {"name": name})
+    if isinstance(data, dict):
+        result["errors"] += len(data.get("errors", []) or [])
+    diff = list(difflib.unified_diff(before["route"].splitlines(), 
route.splitlines(), lineterm="", n=0))
+    diffp = list(difflib.unified_diff(before["props"].splitlines(), 
props.splitlines(), lineterm="", n=0))
+    result["changed_lines"] = sum(1 for l in diff + diffp if 
(l.startswith("+") or l.startswith("-")) and not l.startswith(("+++", "---")))
+    result["ok"] = result["file_ok"] and result["props_ok"] and 
result["log_ok"] and result["errors"] == 0
+    result["log_sample"] = msgs[:5]
+    return result, route, props
+
+
+def main():
+    cfg = json.load(open(os.path.join(HERE, os.environ.get("BENCH_STEPS", 
"steps.json"))))
+    project = cfg["project"]
+    if not os.path.isabs(project):
+        project = os.path.join(HERE, project)
+    os.makedirs(OUT, exist_ok=True)
+    log = open(os.path.join(OUT, "run.log"), "a")
+    mcp = McpClient(MCP_URL); mcp.initialize(); all_tools = mcp.list_tools()
+    tools = to_ollama_tools(all_tools, SHARED + EXTRA)
+    print(f"tools: {[t['function']['name'] for t in tools]}", file=log, 
flush=True)
+
+    # start the integration once, in dev mode, through the MCP server
+    data, raw = jcall(mcp, "camel_run", {"directory": project})
+    print(f"camel_run -> {raw[:300]}", file=log, flush=True)
+    name = (data or {}).get("name") or os.path.basename(project)
+    time.sleep(6)
+
+    messages = [{"role": "system", "content": SYSTEM.replace("{directory}", 
project).replace("{name}", name)}]
+    results = []
+    try:
+        for step in cfg["steps"]:
+            sid = step["id"]
+            before = {"route": read(os.path.join(project, cfg["route_file"])), 
"props": read(os.path.join(project, cfg["props_file"]))}
+            trace = open(os.path.join(OUT, f"step{sid}.trace.jsonl"), "w")
+            messages.append({"role": "user", "content": step["request"]})
+            calls = 0; tokens = 0; t0 = time.time(); writes = 0; refused = 0; 
answer = ""
+            while True:
+                try:
+                    data, secs = ollama_chat(messages, tools)
+                except Exception as e:
+                    trace.write(json.dumps({"error": str(e)}) + "\n"); break
+                msg = data["message"]; tokens += data.get("eval_count", 0)
+                trace.write(json.dumps({"secs": round(secs, 1), "eval": 
data.get("eval_count"), "prompt_eval": data.get("prompt_eval_count"),
+                                        "tool_calls": msg.get("tool_calls"), 
"content": (msg.get("content") or "")[:500]}) + "\n"); trace.flush()
+                messages.append({"role": "assistant", "content": 
msg.get("content") or "", "tool_calls": msg.get("tool_calls")})
+                if msg.get("tool_calls") and calls < MAX_TOOL_CALLS:
+                    for tc in msg["tool_calls"]:
+                        fn = tc["function"]; calls += 1
+                        args = fn.get("arguments") or {}
+                        if isinstance(args, str):
+                            try:
+                                args = json.loads(args)
+                            except Exception:
+                                args = {}
+                        args = dict(args)
+                        if fn["name"] in ("camel_get_files", 
"camel_write_file", "camel_validate_source", "camel_run") and "directory" not 
in args:
+                            args["directory"] = project
+                        if fn["name"] in ("camel_get_log", "camel_get_errors", 
"camel_control", "camel_eval_expression") and "name" not in args:
+                            args["name"] = name
+                        if fn["name"] == "camel_write_file":
+                            writes += 1
+                        try:
+                            out = mcp.call(fn["name"], args)
+                        except Exception as e:
+                            out = "ERROR: " + str(e)
+                        if fn["name"] == "camel_write_file" and ('"invalid"' 
in out or out.startswith("ERROR")):
+                            refused += 1
+                        out = out[:TOOL_RESULT_CAP]
+                        trace.write(json.dumps({"tool": fn["name"], "args": 
{k: (v if k != "content" else v[:1500]) for k, v in args.items()}, "result": 
out[:800]}) + "\n"); trace.flush()
+                        messages.append({"role": "tool", "content": out, 
"tool_name": fn["name"]})
+                    continue
+                answer = msg.get("content") or ""
+                break
+            res, route_after, props_after = score(step, project, cfg, mcp, 
name, before)
+            res.update({"step": sid, "request": step["request"], "tool_calls": 
calls, "writes": writes, "refused_writes": refused,
+                        "seconds": round(time.time() - t0, 1), "tokens": 
tokens, "answer": answer[:400]})
+            results.append(res)
+            with open(os.path.join(OUT, f"step{sid}.after.yaml"), "w") as f:
+                f.write(route_after)
+            print(f"step{sid}: ok={res['ok']} file={res['file_ok']} 
props={res['props_ok']} log={res['log_ok']} errors={res['errors']} "
+                  f"changed_lines={res['changed_lines']} calls={calls} 
writes={writes} refused={refused} secs={res['seconds']} tokens={tokens}", 
file=log, flush=True)
+            trace.close()
+            if step.get("reference"):
+                for fname, content in step["reference"].items():
+                    with open(os.path.join(project, fname), "w") as f:
+                        f.write(content)
+                time.sleep(4)
+                if not res["ok"]:
+                    messages.append({"role": "user", "content": "I fixed that 
step myself; the files now contain the correct version. Continue with the next 
request."})
+            json.dump(results, open(os.path.join(OUT, "results.json"), "w"), 
indent=1)
+    finally:
+        data, raw = jcall(mcp, "camel_control", {"name": name, "action": 
"stop"})
+        print(f"camel_control stop -> {raw[:200]}", file=log, flush=True)
+    print(f"DONE passed={sum(1 for r in results if r['ok'])}/{len(results)}", 
file=log, flush=True)
+    log.close()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/ai-benchmark/examples.json b/ai-benchmark/examples.json
new file mode 100644
index 0000000..ae62d26
--- /dev/null
+++ b/ai-benchmark/examples.json
@@ -0,0 +1,15 @@
+[
+  {"name": "timer-log", "prompt": "Simple timer that logs a hello message 
every second", "expect": "a log line with a hello message roughly once per 
second", "run_seconds": 8},
+  {"name": "cron-log", "prompt": "Scheduled task that logs every 5 seconds", 
"expect": "a log line every 5 seconds driven by a cron schedule", 
"run_seconds": 12},
+  {"name": "rest-api", "prompt": "REST API with hello endpoints", "expect": 
"an HTTP server starts and GET on a hello endpoint returns a greeting", 
"run_seconds": 12, "probe": "curl -s -m 3 http://localhost:8080/api/hello; 
echo; curl -s -m 3 http://localhost:8080/hello; echo"},
+  {"name": "routes", "prompt": "Define routes in YAML with Java beans", 
"expect": "a YAML route calls a Java bean and logs the bean's output", 
"run_seconds": 8},
+  {"name": "tui-hello-world", "prompt": "Say hello via TUI Send Message (F2) 
or CLI", "expect": "a route that can receive a message sent from the CLI or TUI 
and logs a hello for it", "run_seconds": 8},
+  {"name": "content-based-router", "prompt": "Route messages to different 
destinations based on message content using the Choice EIP", "expect": 
"messages with different content are logged to different branches via 
choice/when/otherwise", "run_seconds": 8},
+  {"name": "splitter", "prompt": "Split a batch of items into individual 
messages for processing", "expect": "one batch message is split and each item 
is logged separately", "run_seconds": 8},
+  {"name": "aggregator", "prompt": "Collect individual messages into a batch 
using the Aggregator EIP", "expect": "several individual messages are 
aggregated and one batch log line appears", "run_seconds": 10},
+  {"name": "circuit-breaker", "prompt": "Use the circuit breaker EIP for fault 
tolerance", "expect": "a circuitBreaker EIP wraps a failing step and the route 
keeps running", "run_seconds": 10},
+  {"name": "groovy", "prompt": "Use Groovy with extra dependencies and 
content-based routing", "expect": "a Groovy expression using a third-party 
dependency drives a choice and the result is logged", "run_seconds": 10},
+  {"name": "xslt", "prompt": "Basic XML transformation using XSLT style 
sheets", "expect": "an XML input is transformed by an XSLT stylesheet and the 
result is logged", "run_seconds": 8},
+  {"name": "message-size", "prompt": "Track message body and header sizes per 
endpoint", "expect": "routes produce messages of different sizes to different 
endpoints so sizes can be tracked", "run_seconds": 8},
+  {"name": "memory-leak", "prompt": "Simulates a memory leak for testing JFR 
Old Object Sample diagnostics", "expect": "a route keeps allocating and 
retaining memory and logs growth", "run_seconds": 8}
+]
diff --git a/ai-benchmark/gen_local.py b/ai-benchmark/gen_local.py
new file mode 100755
index 0000000..8d6edb5
--- /dev/null
+++ b/ai-benchmark/gen_local.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Generate Camel examples from one-line prompts with a local Ollama model.
+
+Condition B1: bare prompt, no tools.  Writes files under 
local/<name>/attempt1/.
+Condition B2 is applied later by bench_run.py: if attempt1 fails validation, 
the
+validator output is fed back once and the result goes to 
local/<name>/attempt2/.
+"""
+import json, os, re, sys, time, urllib.request
+
+MODEL = os.environ.get("BENCH_MODEL", "qwen3.6:35b-a3b")
+HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434";)
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(HERE, "local")
+
+SYSTEM = """You are an Apache Camel 4.23 expert. The user wants a small 
runnable example for the Camel CLI (camel-jbang).
+Write it in Camel YAML DSL. Use the file extension .camel.yaml for routes. If 
the example needs extra files
+(application.properties, a Java bean, an XSLT stylesheet, an input file), 
include them too.
+Do not use Maven, Spring Boot or Quarkus. Do not explain. Output only files, 
each in this exact format:
+
+=== FILE: <filename> ===
+<file content>
+
+Output nothing before the first '=== FILE:' line and nothing after the last 
file."""
+
+
+def chat(messages):
+    body = json.dumps({"model": MODEL, "messages": messages, "stream": False,
+                       "options": {"temperature": 0.2, "num_ctx": 
16384}}).encode()
+    req = urllib.request.Request(HOST + "/api/chat", data=body, 
headers={"Content-Type": "application/json"})
+    t0 = time.time()
+    with urllib.request.urlopen(req, timeout=900) as r:
+        data = json.load(r)
+    return data["message"]["content"], time.time() - t0, data
+
+
+def strip_think(text):
+    return re.sub(r"<think>.*?</think>", "", text, flags=re.S).strip()
+
+
+def write_files(text, folder):
+    os.makedirs(folder, exist_ok=True)
+    text = strip_think(text)
+    parts = re.split(r"^=== FILE: (.+?) ===\s*$", text, flags=re.M)
+    # run 11 on: a file block written inside a ``` fence ends at the closing 
fence, so an explanation the model
+    # appends after the last fence no longer lands in that file (runs 1-10 
folded it into the file, and the
+    # validator then named it, which cost a round or the example)
+    fenced = os.environ.get("BENCH_FENCE_AWARE", "1") == "1"
+    def unfence(content):
+        lines = content.strip("\n").split("\n")
+        if fenced and lines and lines[0].strip().startswith("```"):
+            body = []
+            for l in lines[1:]:
+                if l.strip().startswith("```"):
+                    break
+                body.append(l)
+            return "\n".join(body)
+        return "\n".join(l for l in lines if not l.strip().startswith("```"))
+    parts = [parts[0]] + [unfence(p) if i % 2 == 0 else p for i, p in 
enumerate(parts[1:], 1)]
+    written = []
+    if len(parts) < 3:
+        # model ignored the format; dump as a single yaml file
+        with open(os.path.join(folder, "route.camel.yaml"), "w") as f:
+            f.write(text.strip() + "\n")
+        return ["route.camel.yaml (fallback)"]
+    for i in range(1, len(parts), 2):
+        name = os.path.basename(parts[i].strip())
+        content = parts[i + 1].strip("\n") + "\n"
+        with open(os.path.join(folder, name), "w") as f:
+            f.write(content)
+        written.append(name)
+    return written
+
+
+def main():
+    examples = json.load(open(os.path.join(HERE, "examples.json")))
+    only = sys.argv[1:]
+    log = open(os.path.join(HERE, "gen_local.log"), "a")
+    for ex in examples:
+        if only and ex["name"] not in only:
+            continue
+        folder = os.path.join(OUT, ex["name"], "attempt1")
+        if os.path.exists(os.path.join(folder, "raw.txt")):
+            continue
+        user = f"Create a runnable Camel CLI example: {ex['prompt']}."
+        msgs = [{"role": "system", "content": SYSTEM}, {"role": "user", 
"content": user}]
+        try:
+            text, secs, data = chat(msgs)
+        except Exception as e:
+            print(f"{ex['name']}: ERROR {e}", file=log, flush=True)
+            continue
+        os.makedirs(folder, exist_ok=True)
+        with open(os.path.join(folder, "raw.txt"), "w") as f:
+            f.write(text)
+        with open(os.path.join(folder, "messages.json"), "w") as f:
+            json.dump(msgs, f)
+        files = write_files(text, folder)
+        ev = data.get("eval_count", 0); pe = data.get("prompt_eval_count", 0)
+        print(f"{ex['name']}: {secs:.1f}s prompt={pe} gen={ev} files={files}", 
file=log, flush=True)
+    log.close()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/ai-benchmark/mcp_client.py b/ai-benchmark/mcp_client.py
new file mode 100755
index 0000000..7de899f
--- /dev/null
+++ b/ai-benchmark/mcp_client.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""Minimal MCP Streamable HTTP client (protocol 2025-03-26) for 
camel-jbang-mcp."""
+import json, os, urllib.request, itertools
+
+
+class McpClient:
+    def __init__(self, url=os.environ.get("MCP_URL", 
"http://localhost:9090/mcp";)):
+        self.url = url
+        self.session = None
+        self._ids = itertools.count(1)
+        self.tools = []
+
+    def _post(self, payload, expect_result=True):
+        data = json.dumps(payload).encode()
+        headers = {"Content-Type": "application/json", "Accept": 
"application/json, text/event-stream"}
+        if self.session:
+            headers["Mcp-Session-Id"] = self.session
+        req = urllib.request.Request(self.url, data=data, headers=headers)
+        with urllib.request.urlopen(req, timeout=120) as r:
+            sid = r.headers.get("Mcp-Session-Id")
+            if sid:
+                self.session = sid
+            ctype = r.headers.get("Content-Type", "")
+            body = r.read().decode()
+        if not expect_result:
+            return None
+        if "text/event-stream" in ctype:
+            # take the last JSON data: line
+            msgs = [l[5:].strip() for l in body.splitlines() if 
l.startswith("data:")]
+            for m in reversed(msgs):
+                try:
+                    obj = json.loads(m)
+                except json.JSONDecodeError:
+                    continue
+                if "result" in obj or "error" in obj:
+                    return obj
+            raise RuntimeError("no JSON-RPC result in SSE body: " + body[:300])
+        return json.loads(body) if body.strip() else None
+
+    def initialize(self):
+        res = self._post({"jsonrpc": "2.0", "id": next(self._ids), "method": 
"initialize",
+                          "params": {"protocolVersion": "2025-03-26", 
"capabilities": {},
+                                     "clientInfo": {"name": "bench", 
"version": "0"}}})
+        self._post({"jsonrpc": "2.0", "method": "notifications/initialized"}, 
expect_result=False)
+        return res
+
+    def list_tools(self):
+        tools, cursor = [], None
+        while True:
+            params = {"cursor": cursor} if cursor else {}
+            res = self._post({"jsonrpc": "2.0", "id": next(self._ids), 
"method": "tools/list", "params": params})
+            tools.extend(res["result"]["tools"])
+            cursor = res["result"].get("nextCursor")
+            if not cursor:
+                break
+        self.tools = tools
+        return self.tools
+
+    def call(self, name, arguments):
+        res = self._post({"jsonrpc": "2.0", "id": next(self._ids), "method": 
"tools/call",
+                          "params": {"name": name, "arguments": arguments or 
{}}})
+        if "error" in res:
+            return "ERROR: " + json.dumps(res["error"])[:2000]
+        content = res["result"].get("content", [])
+        text = "\n".join(c.get("text", "") for c in content if c.get("type") 
== "text")
+        if res["result"].get("isError"):
+            text = "ERROR: " + text
+        return text
+
+
+if __name__ == "__main__":
+    c = McpClient()
+    print(json.dumps(c.initialize().get("result", {}).get("serverInfo")))
+    for t in c.list_tools():
+        print(f"{t['name']:45} 
{t.get('description','')[:110].replace(chr(10),' ')}")
+    print("total tools:", len(c.tools))
diff --git a/ai-benchmark/results-2026-09.md b/ai-benchmark/results-2026-09.md
new file mode 100644
index 0000000..10d90d3
--- /dev/null
+++ b/ai-benchmark/results-2026-09.md
@@ -0,0 +1,31 @@
+# Results of the first series, 2026-09-12 to 2026-09-13
+
+Twenty runs of the same 13 one-shot examples and 8 stepwise edits with the 
same local model (`qwen3.6:35b-a3b` via Ollama
+on an Apple M4 Pro, 64 GB), against the Camel MCP server. What changed between 
runs was Camel: after each run every failed
+attempt was read, the unclear message became a hint or a check, and the fix 
was merged before the next run. The story is
+in the blog post: 
https://camel.apache.org/blog/2026/09/camel-local-model-benchmark/
+
+Column "before" is the Camel main branch of 2026-09-12 with the shared 
authoring tools (CAMEL-24695) only. Runs 1 to 20
+add the fixes as they were made; run 2 was stopped and is not listed. 
Everything measured here is in Camel 4.23.
+
+| | before | run1 | run3 | run4 | run5 | run6 | run7 | run8 | run9 | run10 | 
run11 | run12 | run13 | run14 | run15 | run16 | run17 | run18 | run19 | run20 |
+|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
+| One-shot passes of 13 | 10 | 7 | 10 | 10 | 9 | 10 | 8 | 10 | 13 | 10 | 9 | 
11 | 10 | 10 | 10 | 10 | 11 | 10 | 12 | 12 |
+| One-shot first-round passes | 5 | 1 | 2 | 5 | 4 | 4 | 4 | 4 | 3 | 2 | 4 | 3 
| 4 | 5 | 5 | 4 | 5 | 5 | 9 | 7 |
+| One-shot rounds / tool calls | 28 / 84 | 33 / 82 | 29 / 79 | 26 / 70 | 28 / 
51 | 28 / 70 | 28 / 58 | 29 / 83 | 26 / 71 | 32 / 65 | 27 / 74 | 28 / 70 | 26 / 
65 | 24 / 60 | 25 / 53 | 25 / 59 | 24 / 66 | 24 / 60 | 19 / 50 | 21 / 52 |
+| One-shot tokens | 169,596 | 103,908 | 63,030 | 75,355 | 72,695 | 110,119 | 
83,348 | 132,131 | 42,482 | 80,185 | 81,556 | 106,498 | 52,370 | 104,892 | 
53,839 | 52,828 | 47,067 | 97,521 | 60,105 | 61,173 |
+| Stepwise passes of 8 (strict / lenient) | 5 / 5 | 5 / 6 | 8 / 8 | 7 / 8 | 8 
/ 8 | 7 / 8 | 8 / 8 | 7 / 7 | 8 / 8 | 8 / 8 | 8 / 8 | 8 / 8 | 7 / 7 | 8 / 8 | 8 
/ 8 | 8 / 8 | 8 / 8 | 8 / 8 | 8 / 8 | 7 / 8 |
+| Stepwise tool calls / refused writes | 34 / 9 | 34 / 11 | 31 / 2 | 20 / 2 | 
31 / 4 | 24 / 3 | 21 / 2 | 27 / 4 | 22 / 2 | 19 / 3 | 40 / 9 | 30 / 4 | 22 / 3 
| 15 / 2 | 19 / 2 | 23 / 2 | 19 / 2 | 26 / 2 | 20 / 2 | 24 / 2 |
+| Stepwise tokens | 41,264 | 19,773 | 7,896 | 6,567 | 9,975 | 10,778 | 9,901 | 
11,390 | 8,201 | 12,843 | 15,424 | 11,421 | 8,755 | 9,277 | 6,370 | 7,802 | 
9,365 | 11,834 | 7,629 | 9,975 |
+| Suite wall clock (sleep stalls removed) | 72 min (56 + 16) | 45 min (36 + 9) 
| 31 min (25 + 6) | 33 min (28 + 4) | 35 min (28 + 6) | 45 min (39 + 6) | 38 
min (31 + 6) | 52 min (46 + 6), 47 min asleep | 116 min (110 + 5), 46 min 
asleep | 39 min (33 + 6) | 38 min (29 + 8) | 44 min (37 + 6) | 29 min (24 + 5) 
| 42 min (37 + 5) | 27 min (22 + 4) | 27 min (22 + 5) | 25 min (20 + 5) | 41 
min (34 + 6) | 28 min (23 + 5) | 29 min (23 + 6) |
+| One-shot failures | rest-api, aggregator, memory-leak | rest-api, routes, 
tui-hello-world, aggregator, circuit-breaker, groovy | content-based-router, 
aggregator, memory-leak | aggregator, xslt, memory-leak | splitter, aggregator, 
xslt, message-size | routes, groovy, memory-leak | routes, splitter, 
aggregator, groovy, memory-leak | circuit-breaker, xslt, memory-leak | none | 
routes, groovy, xslt | routes, aggregator, xslt, message-size | xslt, 
memory-leak | content-based-router, aggreg [...]
+| Stepwise failures | 6, 7, 8 | 2, 4, 6 | none | 2 | none | 2 | none | 7 | 
none | none | none | none | 4 | none | none | none | none | none | none | 2 |
+
+Reading the table:
+
+- One-shot passes went from 7 (strict) to 12 of 13; first-round passes from 5 
to 9 (run 19); tokens from 170k to about 60k.
+- The stepwise edits reached 8 of 8 from run 3 on and stayed there.
+- From run 14 the remaining one-shot failures were the model's (thinking 
spirals of 20k to 27k tokens, answering with
+  pasted files instead of the write tool after a correct hint), not Camel's; 
the series stopped at run 20 for that reason.
+- The wall clock removes the time the laptop was asleep (a model call longer 
than 600 s); `caffeinate` in `run-suite.sh`
+  keeps it awake now.
diff --git a/ai-benchmark/run-suite.sh b/ai-benchmark/run-suite.sh
new file mode 100755
index 0000000..5ce2551
--- /dev/null
+++ b/ai-benchmark/run-suite.sh
@@ -0,0 +1,23 @@
+#!/bin/zsh
+# Runs one full suite: the one-shot examples, then the stepwise edits. Usage: 
run-suite.sh <tag>
+# Results: oneshot-<tag>/, stepwise/<tag>/, <tag>.log (wall clock). Summarise 
with: summarize_runs.py <tag>
+set -u
+TAG="${1:?usage: run-suite.sh <tag>}"
+cd "$(dirname "$0")"
+export MCP_URL="${MCP_URL:-http://127.0.0.1:9090/mcp}";
+export BENCH_VALIDATE_PROPS=1
+export BENCH_VALIDATE_SOURCE=1
+# the one-shot model gets catalog lookups and validation only: no example 
catalog (that would hand it the answer), no runtime tools
+export 
BENCH_TOOL_ALLOW="${BENCH_TOOL_ALLOW:-^camel_(catalog_(components|component_doc|eips|eip_doc|languages|language_doc|dataformats|dataformat_doc|docs|doc|find|sample)|validate_(yaml_dsl|route|source)|component_properties|configuration_validate|error_diagnose|eval_expression)$}"
+command -v caffeinate > /dev/null && caffeinate -i -s -w $$ &   # macOS: keep 
the machine awake for the hour
+echo "[$TAG] one-shot start $(date +%T)" | tee -a "$TAG.log"
+BENCH_OUT="oneshot-$TAG" python3 agent_local.py > "oneshot-$TAG.out" 2>&1
+echo "[$TAG] one-shot done $(date +%T)" | tee -a "$TAG.log"
+# the stepwise project starts from the timer-log example every time
+git checkout -q -- stepwise-project 2>/dev/null || true
+echo "[$TAG] stepwise start $(date +%T)" | tee -a "$TAG.log"
+BENCH_TAG="$TAG" python3 agent_mcp_stepwise.py > "stepwise-$TAG.out" 2>&1
+echo "[$TAG] stepwise done $(date +%T)" | tee -a "$TAG.log"
+git checkout -q -- stepwise-project 2>/dev/null || true
+echo "[$TAG] DONE" | tee -a "$TAG.log"
+python3 summarize_runs.py "$TAG"
diff --git a/ai-benchmark/run_one.sh b/ai-benchmark/run_one.sh
new file mode 100755
index 0000000..1eff64c
--- /dev/null
+++ b/ai-benchmark/run_one.sh
@@ -0,0 +1,44 @@
+#!/bin/zsh
+# Usage: run_one.sh <folder> <seconds> [probe command]
+# Validates every *.camel.yaml in <folder> with `camel validate yaml`, then 
runs the
+# folder for <seconds> and captures the log. Optional probe runs 6s after 
start.
+set -u
+folder="$1"; secs="$2"; probe="${3:-}"
+cd "$folder" || exit 2
+: > validate.log; : > run.log; : > probe.log
+files=(*.camel.yaml(N) *.yaml(N))
+if (( ${#files} == 0 )); then echo "no yaml files" > validate.log; fi
+for f in ${(u)files}; do
+  echo "### $f" >> validate.log
+  camel validate yaml "$f" >> validate.log 2>&1
+  echo "exit=$?" >> validate.log
+done
+# properties files: camel.* keys against the catalog (camel validate 
properties, CAMEL-24698)
+if [[ "${BENCH_VALIDATE_SOURCE:-0}" == "1" ]]; then
+  for p in *.java(N) *.xsl(N) *.xslt(N) *.xml(N); do
+    echo "### $p" >> validate.log
+    camel validate source "$p" >> validate.log 2>&1
+    echo "exit=$?" >> validate.log
+  done
+fi
+if [[ "${BENCH_VALIDATE_PROPS:-0}" == "1" ]]; then
+  for p in *.properties(N); do
+    echo "### $p" >> validate.log
+    camel validate properties "$p" >> validate.log 2>&1
+    echo "exit=$?" >> validate.log
+  done
+fi
+# run everything in the folder (yaml, java, properties are picked up by camel 
run *)
+( camel run * --max-seconds="$secs" --logging-color=false > run.log 2>&1 ) &
+pid=$!
+# watchdog: a camel.main.durationMaxSeconds in the example's own 
application.properties overrides --max-seconds
+# (run 9 memory-leak ran for an hour); kill the run after the expected time 
plus a grace period
+( sleep $((secs + 45)); pkill -TERM -f -- "--max-seconds=$secs 
--logging-color=false" 2>/dev/null; kill -TERM $pid 2>/dev/null ) &
+watchdog=$!
+if [[ -n "$probe" ]]; then
+  sleep 7
+  eval "$probe" > probe.log 2>&1
+fi
+wait $pid
+echo "run-exit=$?" >> run.log
+kill $watchdog 2>/dev/null
diff --git a/ai-benchmark/start-server.sh b/ai-benchmark/start-server.sh
new file mode 100755
index 0000000..9bc15de
--- /dev/null
+++ b/ai-benchmark/start-server.sh
@@ -0,0 +1,14 @@
+#!/bin/zsh
+# Starts the Camel MCP server with HTTP transport on port 9090 (camel mcp 
--http) and waits until it answers.
+# Set CAMEL_MCP_JAR to run a locally built camel-jbang-mcp runner jar instead 
of the CLI plugin.
+cd "$(dirname "$0")"
+PORT="${MCP_PORT:-9090}"
+pkill -f "camel-jbang-mcp.*runner.jar" 2>/dev/null; pkill -f "camel mcp 
--http" 2>/dev/null; sleep 1
+if [[ -n "${CAMEL_MCP_JAR:-}" ]]; then
+  nohup sh -c "tail -f /dev/null | java -Dquarkus.http.host-enabled=true 
-Dquarkus.http.host=0.0.0.0 -Dquarkus.http.port=$PORT -Dquarkus.log.level=WARN 
-jar $CAMEL_MCP_JAR" > mcp-server.log 2>&1 &
+else
+  nohup sh -c "tail -f /dev/null | camel mcp --http --port=$PORT 
--log-level=WARN" > mcp-server.log 2>&1 &
+fi
+export MCP_URL="http://127.0.0.1:$PORT/mcp";
+for i in $(seq 1 45); do sleep 2; python3 mcp_client.py > /dev/null 2>&1 && { 
echo "server up after $((i*2))s at $MCP_URL"; python3 mcp_client.py | tail -1; 
exit 0; }; done
+echo "server did not come up; see mcp-server.log"; tail -20 mcp-server.log; 
exit 1
diff --git a/ai-benchmark/steps.json b/ai-benchmark/steps.json
new file mode 100644
index 0000000..3bfa65a
--- /dev/null
+++ b/ai-benchmark/steps.json
@@ -0,0 +1,117 @@
+{
+ "project": "stepwise-project",
+ "route_file": "timer-log.camel.yaml",
+ "props_file": "application.properties",
+ "wait_seconds": 13,
+ "steps": [
+  {
+   "id": 1,
+   "request": "Change the timer so it fires every 5 seconds instead of every 
second.",
+   "check": {
+    "props_regex": "timer\\.period\\s*=\\s*5000|period.*5000",
+    "log_regex": "Hello Camel",
+    "min_log": 1,
+    "interval_min": 4.5
+   },
+   "reference": {
+    "timer-log.camel.yaml": "- route:\n    id: timer-log\n    from:\n      
uri: timer\n      parameters:\n        timerName: tick\n        period: 
\"{{timer.period}}\"\n      steps:\n        - setBody:\n            
expression:\n              simple:\n                expression: 
\"{{greeting.message}}\"\n        - log:\n            message: \"${body}\"\n",
+    "application.properties": "timer.period=5000\ngreeting.message=Hello 
Camel!\n"
+   }
+  },
+  {
+   "id": 2,
+   "request": "Set the body to a random number between 0 and 40 instead of the 
greeting message.",
+   "check": {
+    "file_regex": "random",
+    "log_regex": "^\\d{1,2}$",
+    "min_log": 1
+   },
+   "reference": {
+    "timer-log.camel.yaml": "- route:\n    id: timer-log\n    from:\n      
uri: timer\n      parameters:\n        timerName: tick\n        period: 
\"{{timer.period}}\"\n      steps:\n        - setBody:\n            
expression:\n              simple:\n                expression: 
\"${random(0,40)}\"\n        - log:\n            message: \"${body}\"\n",
+    "application.properties": "timer.period=5000\ngreeting.message=Hello 
Camel!\n"
+   }
+  },
+  {
+   "id": 3,
+   "request": "Add a choice: when the body is 30 or more, log \"hot: <the 
body>\", otherwise log \"normal: <the body>\".",
+   "check": {
+    "file_regex": "choice",
+    "log_regex": "^(hot|normal): \\d+",
+    "min_log": 1
+   },
+   "reference": {
+    "timer-log.camel.yaml": "- route:\n    id: timer-log\n    from:\n      
uri: timer\n      parameters:\n        timerName: tick\n        period: 
\"{{timer.period}}\"\n      steps:\n        - setBody:\n            
expression:\n              simple:\n                expression: 
\"${random(0,40)}\"\n        - choice:\n            when:\n              - 
simple: \"${body} >= 30\"\n                steps:\n                  - log:\n   
                   message: \"hot: ${body}\"\n             [...]
+    "application.properties": "timer.period=5000\ngreeting.message=Hello 
Camel!\n"
+   }
+  },
+  {
+   "id": 4,
+   "request": "Instead of logging the hot ones, send them to the seda:hot 
queue. Keep logging the normal ones.",
+   "check": {
+    "file_regex": "seda:hot",
+    "file_not_regex": "hot: \\$",
+    "log_regex": "^normal: \\d+",
+    "min_log": 0
+   },
+   "reference": {
+    "timer-log.camel.yaml": "- route:\n    id: timer-log\n    from:\n      
uri: timer\n      parameters:\n        timerName: tick\n        period: 
\"{{timer.period}}\"\n      steps:\n        - setBody:\n            
expression:\n              simple:\n                expression: 
\"${random(0,40)}\"\n        - choice:\n            when:\n              - 
simple: \"${body} >= 30\"\n                steps:\n                  - to:\n    
                  uri: seda:hot\n            otherwise:\n  [...]
+    "application.properties": "timer.period=5000\ngreeting.message=Hello 
Camel!\n"
+   }
+  },
+  {
+   "id": 5,
+   "request": "Add a second route with id hot-consumer that consumes from 
seda:hot and logs \"hot from <the route id>: <the body>\".",
+   "check": {
+    "file_regex": "hot-consumer",
+    "log_regex": "^(hot from hot-consumer: \\d+|normal: \\d+)",
+    "min_log": 1,
+    "routes": 2
+   },
+   "reference": {
+    "timer-log.camel.yaml": "- route:\n    id: timer-log\n    from:\n      
uri: timer\n      parameters:\n        timerName: tick\n        period: 
\"{{timer.period}}\"\n      steps:\n        - setBody:\n            
expression:\n              simple:\n                expression: 
\"${random(0,40)}\"\n        - choice:\n            when:\n              - 
simple: \"${body} >= 30\"\n                steps:\n                  - to:\n    
                  uri: seda:hot\n            otherwise:\n  [...]
+    "application.properties": "timer.period=5000\ngreeting.message=Hello 
Camel!\n"
+   }
+  },
+  {
+   "id": 6,
+   "request": "Add error handling so that if any exception happens in a route, 
the error is logged and the message continues without failing.",
+   "check": {
+    "file_regex": "onException|errorHandler",
+    "log_regex": "^(hot from hot-consumer: \\d+|normal: \\d+)",
+    "min_log": 1,
+    "routes": 2
+   },
+   "reference": {
+    "timer-log.camel.yaml": "- onException:\n    exception:\n      - 
java.lang.Exception\n    continued:\n      constant: \"true\"\n    steps:\n     
 - log:\n          message: \"error: ${exception.message}\"\n- route:\n    id: 
timer-log\n    from:\n      uri: timer\n      parameters:\n        timerName: 
tick\n        period: \"{{timer.period}}\"\n      steps:\n        - setBody:\n  
          expression:\n              simple:\n                expression: 
\"${random(0,40)}\"\n        - c [...]
+    "application.properties": "timer.period=5000\ngreeting.message=Hello 
Camel!\n"
+   }
+  },
+  {
+   "id": 7,
+   "request": "Move the 30 threshold into application.properties as 
hot.threshold and use it from the route.",
+   "check": {
+    "file_regex": 
"\\{\\{hot\\.threshold\\}\\}|\\$\\{properties:hot\\.threshold\\}",
+    "props_regex": "hot\\.threshold\\s*=\\s*30",
+    "log_regex": "^(hot from hot-consumer: \\d+|normal: \\d+)",
+    "min_log": 1,
+    "routes": 2
+   },
+   "reference": {
+    "timer-log.camel.yaml": "- onException:\n    exception:\n      - 
java.lang.Exception\n    continued:\n      constant: \"true\"\n    steps:\n     
 - log:\n          message: \"error: ${exception.message}\"\n- route:\n    id: 
timer-log\n    from:\n      uri: timer\n      parameters:\n        timerName: 
tick\n        period: \"{{timer.period}}\"\n      steps:\n        - setBody:\n  
          expression:\n              simple:\n                expression: 
\"${random(0,40)}\"\n        - c [...]
+    "application.properties": "timer.period=5000\ngreeting.message=Hello 
Camel!\nhot.threshold=30\n"
+   }
+  },
+  {
+   "id": 8,
+   "request": "Rename the first route id from timer-log to sensor, and make 
its normal log step use the logger name sensor.",
+   "check": {
+    "file_regex": "id: sensor",
+    "file_regex2": "logName: sensor|loggerName: sensor|logger: sensor",
+    "log_regex": "^(hot from hot-consumer: \\d+|normal: \\d+)",
+    "min_log": 1,
+    "routes": 2
+   },
+   "reference": {}
+  }
+ ]
+}
diff --git a/ai-benchmark/stepwise-project/application.properties 
b/ai-benchmark/stepwise-project/application.properties
new file mode 100644
index 0000000..370577a
--- /dev/null
+++ b/ai-benchmark/stepwise-project/application.properties
@@ -0,0 +1,4 @@
+# Timer period in milliseconds
+timer.period=1000
+# Greeting message to log
+greeting.message=Hello Camel!
diff --git a/ai-benchmark/stepwise-project/timer-log.camel.yaml 
b/ai-benchmark/stepwise-project/timer-log.camel.yaml
new file mode 100644
index 0000000..3e95c9e
--- /dev/null
+++ b/ai-benchmark/stepwise-project/timer-log.camel.yaml
@@ -0,0 +1,15 @@
+- route:
+    id: timer-log
+    from:
+      uri: timer
+      parameters:
+        timerName: tick
+        period: "{{timer.period}}"
+      steps:
+        - setBody:
+            expression:
+              simple:
+                expression: "{{greeting.message}}"
+        - log:
+            message: "${body}"
+
diff --git a/ai-benchmark/summarize_runs.py b/ai-benchmark/summarize_runs.py
new file mode 100755
index 0000000..93001a2
--- /dev/null
+++ b/ai-benchmark/summarize_runs.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Markdown summary of completed runs: one-shot and stepwise results plus the 
suite wall clock.
+
+Usage: summarize_runs.py <tag> [<tag> ...]
+
+A run with tag T has its one-shot results in oneshot-T/<example>/result.json 
(BENCH_OUT=oneshot-T), its stepwise
+results in stepwise/T/results.json (BENCH_TAG=T) and its wall clock in T.log 
(written by run-suite.sh).
+"""
+import json, os, re, sys
+HERE = os.path.dirname(os.path.abspath(__file__))
+names = [e["name"] for e in json.load(open(os.path.join(HERE, 
"examples.json")))]
+tags = sys.argv[1:]
+if not tags:
+    print(__doc__); sys.exit(1)
+def wall(tag):
+    p = os.path.join(HERE, tag + ".log")
+    if not os.path.exists(p):
+        return None
+    t = {}
+    for l in open(p):
+        m = re.search(rf"\[{re.escape(tag)}\] (one-shot|stepwise) (start|done) 
(\d+):(\d+):(\d+)", l)
+        if m:
+            t[(m.group(1), m.group(2))] = int(m.group(3)) * 3600 + 
int(m.group(4)) * 60 + int(m.group(5))
+    try:
+        def span(x):
+            d = t[(x, "done")] - t[(x, "start")]
+            return d + 86400 if d < 0 else d  # a run that crosses midnight
+        return span("one-shot"), span("stepwise")
+    except KeyError:
+        return None
+STALL = 600  # a single model call longer than this is the machine asleep, not 
the model
+def stalls(paths):
+    total = 0.0
+    for p in paths:
+        if not os.path.exists(p):
+            continue
+        for l in open(p):
+            try:
+                o = json.loads(l)
+            except json.JSONDecodeError:
+                continue
+            if isinstance(o.get("secs"), (int, float)) and o["secs"] > STALL:
+                total += o["secs"]
+    return total
+rows = {}
+for tag in tags:
+    d = os.path.join(HERE, "oneshot-" + tag)
+    one = [json.load(open(os.path.join(d, n, "result.json"))) for n in names 
if os.path.exists(os.path.join(d, n, "result.json"))]
+    sp = os.path.join(HERE, "stepwise", tag, "results.json")
+    st = json.load(open(sp)) if os.path.exists(sp) else []
+    w = wall(tag)
+    st1 = stalls(os.path.join(d, n, "trace.jsonl") for n in names)
+    st2 = stalls(os.path.join(HERE, "stepwise", tag, f"step{i}.trace.jsonl") 
for i in range(1, len(st) + 1))
+    if w and (st1 or st2):
+        w = (int(w[0] - st1), int(w[1] - st2))
+    rows[tag] = dict(
+        n=len(one), stall=int(st1 + st2),
+        ok=sum(x["ok"] for x in one), first=sum(1 for x in one if x["ok"] and 
x["rounds"] == 1),
+        rounds=sum(x["rounds"] for x in one), calls=sum(x["tool_calls"] for x 
in one),
+        tok=sum(x["tokens"] for x in one),
+        sn=len(st), sok=sum(x["ok"] for x in st),
+        slen=sum(1 for x in st if x["ok"] or (x["file_ok"] and x["props_ok"] 
and x["errors"] == 0)),
+        scalls=sum(x["tool_calls"] for x in st), 
srefused=sum(x["refused_writes"] for x in st), stok=sum(x["tokens"] for x in 
st),
+        wall=(f"{(w[0]+w[1])//60} min ({w[0]//60} + {w[1]//60})" if w else 
"?"),
+        fails=[n for n, x in zip(names, one) if not x["ok"]], sfails=[i + 1 
for i, x in enumerate(st) if not x["ok"]])
+def line(label, f):
+    return "| " + label + " | " + " | ".join(f(rows[t]) for t in tags) + " |"
+print("| | " + " | ".join(tags) + " |"); print("|---|" + "---|" * len(tags))
+print(line("One-shot passes", lambda x: f"{x['ok']} of {x['n']}"))
+print(line("One-shot first-round passes", lambda x: str(x["first"])))
+print(line("One-shot rounds / tool calls", lambda x: f"{x['rounds']} / 
{x['calls']}"))
+print(line("One-shot tokens", lambda x: f"{x['tok']:,}"))
+print(line("Stepwise passes (strict / lenient)", lambda x: f"{x['sok']} / 
{x['slen']} of {x['sn']}"))
+print(line("Stepwise tool calls / refused writes", lambda x: f"{x['scalls']} / 
{x['srefused']}"))
+print(line("Stepwise tokens", lambda x: f"{x['stok']:,}"))
+print(line("Suite wall clock (sleep stalls removed)", lambda x: x["wall"] + 
(f", {x['stall']//60} min asleep" if x["stall"] else "")))
+print(line("One-shot failures", lambda x: ", ".join(x["fails"]) or "none"))
+print(line("Stepwise failures", lambda x: ", ".join(map(str, x["sfails"])) or 
"none"))

Reply via email to