atakan Claude Opus 5 commited on
Commit
9e637cd
·
1 Parent(s): 9009a09

refactor: Collapse four inference backends into one MLX path

Browse files

The project targets Apple Silicon. It carried CUDA/PyTorch, GGUF/llama.cpp,
Ollama and a Hugging Face Spaces ZeroGPU path anyway, and maintaining four
backends for one machine was most of the complexity in the codebase.
orchestrator.py (1,327 lines) and tools.py (427) go; engine.py, agent.py and
toolcall.py replace them.

engine.py keeps one persistent KV cache for the process. _align_cache finds the
longest prefix of the incoming prompt the cache already holds, trims to it, and
feeds only the remainder, so the fixed system-prompt-plus-tool-schema prefix is
prefilled once at startup by prewarm() rather than once per tool step. Generated
tokens are tracked in the cache too, which is what makes continuing a
tool-calling turn nearly free. First-token latency per step: ~6.4s -> ~0.7s.

Sampling uses presence_penalty, not a flat repetition_penalty. A blanket
repetition penalty punishes the repeated structural tokens that matrices and
JSON are made of ("[", "0", ",") exactly when the model is emitting a tool call.
think_budget caps tokens spent inside a <think> block and closes it by hand on
overrun, bounding worst-case latency on a reasoning model.

agent.py streams throughout. _StreamGate releases text as soon as it cannot be
the start of <tool_call>, so prose appears immediately while a tool call never
leaks into the chat. Retrieved passages attach to the user turn, never spliced
into the system prompt, which keeps the cached prefix byte-identical across
questions.

The parameter-provenance check is gone. It refused any matrix it could not trace
back to the user's message, which blocked the single most useful thing the
assistant does -- working an example the user asked for. Schema validation and
sandboxed execution in registry.execute are the real guarantees, and
toolcall.degenerate_reason catches genuine decoding loops with thresholds set
well above any hand-written matrix. prompts.py drops 2,587 tokens to 506 by
stating what to do instead of what not to do; the old "never invent a parameter"
clause was what taught the model to refuse worked examples.

Two model failures are fixed in tool results rather than in prompt wording,
which is the pattern to reach for first:
- continuous_lqr, discrete_lqr and place_state_feedback return closed_loop_A.
Given a correct gain, the model wrote A-BK for a double integrator as
[[-6,1],[-5,-6]] instead of [[0,1],[-6,-5]] -- right gain, wrong write-up.
- bode_analysis returns gain and phase margins. Asked for a phase margin the
model reached for it instead of stability_margins, got back sampled curves,
and correctly but uselessly concluded the margin "cannot be determined".

app.py routes every inference call through one dedicated worker thread for the
process lifetime, since MLX keeps its compute stream in thread-local state, and
serialises turns with a lock because they all mutate the same KV cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

CLAUDE.md ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ Guidance for Claude Code (claude.ai/code) when working in this repository.
4
+
5
+ ## What this is
6
+
7
+ ControlAI is an offline control-systems engineering assistant. A locally held model
8
+ (`mlx-community/Qwen3-14B-4bit` by default, via MLX) answers control questions, and every number it
9
+ states comes from a deterministic solver — SciPy/LAPACK/CVXPY behind a validated tool registry —
10
+ never from the model's own arithmetic. A BM25 + dense hybrid retriever over a local control-theory
11
+ corpus grounds conceptual answers. A FastAPI + vanilla-JS console and a terminal CLI are the two
12
+ front ends. Nothing leaves the machine.
13
+
14
+ Apple Silicon only. There is no CUDA, GGUF, Ollama, or Hugging Face Spaces path — an earlier
15
+ version carried all four, and maintaining four backends for one machine was most of the complexity
16
+ in the codebase.
17
+
18
+ ## Commands
19
+
20
+ ```bash
21
+ ./run.sh # web console at http://127.0.0.1:8000
22
+ ./run.sh --cli # interactive terminal chat
23
+ ./run.sh --cli "design an LQR for A=[[0,1],[-2,-3]], B=[[0],[1]], Q=eye(2), R=1"
24
+ ./run.sh --fetch-index # download the prebuilt index from the private Hub dataset repo
25
+ ./run.sh --build-index # rebuild the dense retrieval index (~45 min, checkpointed/resumable)
26
+ ./run.sh --ingest-corpus # merge data/processed/ chunks into the index
27
+ ./run.sh --calibrate # re-measure MIN_COSINE against the current corpus
28
+
29
+ python -m unittest discover tests -v # unittest, not pytest -- pytest is not installed
30
+ python -m unittest tests.test_agent_tools # deterministic numerics
31
+ python -m unittest tests.test_agent_core # parsing, stream gating, truncation
32
+
33
+ pip install -r requirements.txt # runtime
34
+ pip install -r requirements-training.txt # dataset generation / ground truth
35
+ pip install -r requirements-corpus.txt # corpus crawling and extraction
36
+ ```
37
+
38
+ Environment: `CONTROLAI_MODEL`, `CONTROLAI_ADAPTER`, `CONTROLAI_THINKING` (`off|auto|on`),
39
+ `CONTROLAI_THINK_BUDGET`, `CONTROLAI_EMBED_MODEL`.
40
+
41
+ There is no lint or format command configured — don't invent one.
42
+
43
+ ## Architecture
44
+
45
+ ### Request flow
46
+ `web/` (SSE console) or `cli.py` → `app.py` (`/api/chat`, `/api/chat/stream`) →
47
+ `ControlAgent` (`controlai_agent/agent.py`) → `LocalEngine` (`controlai_agent/engine.py`, MLX) and
48
+ `registry.execute` (`controlai_agent/registry.py`) → tools in `controlai_agent/tools/*.py`.
49
+
50
+ ### `engine.py` — inference
51
+ One persistent KV cache lives for the process. `_align_cache` finds the longest prefix of the
52
+ incoming prompt that the cache already holds, trims to that point, and feeds only the remainder, so
53
+ the fixed system-prompt-plus-tool-schema prefix (~8k tokens) is prefilled once at startup by
54
+ `prewarm()` rather than once per tool step. Generated tokens are tracked in the cache too, which is
55
+ what makes continuing a tool-calling turn nearly free. Measured effect: first-token latency fell
56
+ from ~6.4 s per step to ~0.7 s.
57
+
58
+ `stream()` yields tokens as they are produced. `think_budget` caps tokens spent inside a `<think>`
59
+ block and closes it by hand on overrun, bounding worst-case latency on a reasoning model.
60
+
61
+ Sampling uses `presence_penalty`, not a flat `repetition_penalty`. A blanket repetition penalty
62
+ punishes the repeated structural tokens that matrices and JSON are made of (`[`, `0`, `,`) exactly
63
+ when the model is emitting a tool call.
64
+
65
+ ### `agent.py` — the loop
66
+ A short tool-calling loop (`MAX_TOOL_STEPS = 2`, `MAX_CALLS_PER_TOOL = 2`) that streams throughout.
67
+ `_StreamGate` releases text as soon as it cannot be the start of `<tool_call>`, so prose appears
68
+ immediately while a tool call never leaks into the chat. Retrieved passages are attached to the
69
+ *user* turn, never spliced into the system prompt, because that keeps the cached prefix
70
+ byte-identical across questions.
71
+
72
+ **There is deliberately no parameter-provenance check.** An earlier version refused any matrix it
73
+ could not trace back to the user's message. It blocked the single most useful thing the assistant
74
+ does — working an example that the user asked for — so it was removed. Schema validation and
75
+ sandboxed execution in `registry.execute` are the real guarantees; `toolcall.degenerate_reason`
76
+ catches genuine decoding loops with thresholds set well above any hand-written matrix.
77
+
78
+ ### `prompts.py`
79
+ 506 tokens, down from 2,587. States what to do rather than what not to do. The previous prompt's
80
+ "never invent a parameter" clause taught the model to refuse worked examples; the current one
81
+ explicitly authorises choosing an illustrative system when the user asks for a demonstration.
82
+
83
+ ### Tool registry (`registry.py` + `controlai_agent/tools/`)
84
+ Tools are plain functions decorated with `@registry.register(name, description, parameters_schema)`
85
+ across `linear.py`, `frequency.py`, `synthesis.py`, `estimation.py`, `nonlinear.py`, `robust.py`,
86
+ `allocation.py`, `simulation.py`, `matrix_ops.py`, `plotting.py`, `python_executor.py`, `rag.py`.
87
+ `registry.execute()` coerces stringified arrays, validates against JSON Schema, runs the function,
88
+ and rounds every float to 6 significant figures. `controlai_agent/verifier.py` cross-checks results
89
+ with independent invariants — Riccati residual, pole-placement error, CBF forward invariance.
90
+
91
+ **Prefer putting a fact in a tool result over instructing the model to derive it.** Two observed
92
+ failures were fixed that way rather than by prompt wording, and it is the pattern to reach for
93
+ first:
94
+ - `continuous_lqr`, `discrete_lqr` and `place_state_feedback` return `closed_loop_A`. Given a
95
+ correct gain, the model wrote $A-BK$ for a double integrator as `[[-6,1],[-5,-6]]` instead of
96
+ `[[0,1],[-6,-5]]` — right gain, wrong write-up.
97
+ - `bode_analysis` returns the gain/phase margins as well. Asked for a phase margin the model
98
+ reached for it instead of `stability_margins`, got back sampled curves, and correctly but
99
+ uselessly concluded the margin "cannot be determined".
100
+
101
+ ### Retrieval (`controlai_rag/`)
102
+
103
+ Three data bugs were found here and are worth knowing about, because each one silently degraded
104
+ retrieval rather than failing:
105
+ - **Chunk ids were not unique.** `chunk_document` is called once per page and restarted its counter
106
+ each time, so every page's first chunk was `<file>_c0000` — 154 distinct ids across 9,976 chunks.
107
+ Anything keyed on chunk_id resolved to the wrong row. Fixed in `chunker.py` (ids are now
108
+ page-qualified); `scripts/repair_chunk_ids.py` migrated the existing index in place.
109
+ - **Embeddings were pooled without an EOS token.** Qwen3-Embedding pools the last position and was
110
+ trained with `<|endoftext|>` there. Omitting it dropped the margin between relevant and irrelevant
111
+ passages from +0.32 to +0.15. Note the checkpoint's own `eos_token_id` is `<|im_end|>`, which is
112
+ the wrong token here — `controlai_rag/embeddings.py` pins the right one.
113
+ - **71.5% of the Nise textbook was mojibake.** Its PDF has a broken symbol-font ToUnicode map, so
114
+ every extractor returns `L½ f ðtÞ/C138 ¼FðsÞ` for `L[f(t)] = F(s)`. `controlai_rag/textfix.py`
115
+ reverses the substitution (it is deterministic); `scripts/repair_corpus_text.py` migrated the
116
+ index. `document_loader` now applies it on ingest.
117
+
118
+ `document_loader.py` → `chunker.py` → `index.py` (BM25, persisted to `data/rag_index/`) and
119
+ `embeddings.py` (Qwen3-Embedding-0.6B under MLX, last-token pooling, instruction-prefixed queries).
120
+
121
+ **The index holds 80,370 chunks.** 9,976 come from `data/user_docs/` (course notes, Nise, Ogata);
122
+ the other 70,394 were bridged in from `data/processed/*_chunks/` by
123
+ `scripts/ingest_processed_corpus.py`. That bridge did not exist before: the `scripts/` pipeline
124
+ (`raw → extracted → processed`) fed *training dataset generation* only, while `ControlRAGIndex` read
125
+ `user_docs` alone. Retrieval was therefore seeing 12% of the corpus, and none of the canonical
126
+ texts — Doyle/Francis/Tannenbaum, Åström & Murray, Rawlings/Mayne/Diehl, Sontag, Liberzon, Boyd,
127
+ Söderström & Stoica — which were downloaded, extracted and chunked on disk, unread. Re-run the
128
+ bridge after adding anything to `data/processed/`.
129
+
130
+ **The index is not in git.** `chunks.json` (187MB), `embeddings.npz` (144MB) and `bm25.pkl`
131
+ (124MB) are each past GitHub's 100MB per-file limit, and `chunks.json` holds the full extracted text
132
+ of 666 documents including commercial textbooks (Nise, Ogata) — fine to keep locally, wrong to
133
+ redistribute. They live in the **private** Hub dataset repo `atakankahya/controlai-rag-index`;
134
+ `controlai_rag/fetch_index.py` (`./run.sh --fetch-index`) pulls them with your HF token. It copies
135
+ out of the Hub cache rather than symlinking, because the index is mutated in place by uploads and by
136
+ `scripts/repair_*.py`. A public clone has no index and builds its own from `data/user_docs/`.
137
+
138
+ `build()` is checkpointed every 4,000 chunks to `embeddings.partial.npz` and resumes from it. An
139
+ 80k-chunk build takes ~45 minutes, and writing only at the end meant one interruption discarded all
140
+ of it (observed at 72,008 of 80,370).
141
+ `retriever.py` fuses the two rankings with reciprocal rank fusion and gates on cosine similarity
142
+ (`MIN_COSINE = 0.62`). That number is a property of the model *and* the corpus, so re-measure it
143
+ with `./run.sh --calibrate` whenever the corpus changes size materially — it moved when the index
144
+ grew 8x. Current margins: in-domain worst best-match 0.678, off-domain best 0.571. Lexical candidates are scored densely too — without that, a chunk BM25 ranked first but
145
+ that fell outside the dense top-K was dropped for having no score rather than for being irrelevant.
146
+ `_is_low_value` discards back-of-book index pages, which match almost any control query because they
147
+ contain every term in the field, while explaining nothing.
148
+
149
+ The gate matters. BM25 scores are unbounded and corpus-relative, so the old threshold of 2.5 passed
150
+ essentially everything: a question about the Bode sensitivity integral retrieved Routh-Hurwitz
151
+ tables at score 19.7 and injected them as authoritative context. Returning nothing is a valid and
152
+ frequent outcome — the model then answers from its own knowledge.
153
+
154
+ `display_source_name()` maps raw indexed filenames (which carry owner initials, course codes, scan
155
+ artifacts) to clean citable labels. Never let a raw filename reach an answer.
156
+
157
+ Uploads through `/api/upload` are embedded immediately via `HybridRetriever.add_chunks`, or they
158
+ would have no vector and the cosine gate would make them permanently unreachable.
159
+
160
+ ### `app.py` — serving
161
+ Local-only FastAPI. MLX keeps its compute stream in thread-local state, so every inference call
162
+ goes through one dedicated worker thread for the process lifetime; a lock serialises turns because
163
+ they all mutate the same KV cache. `_to_wire_events` translates the agent's event vocabulary
164
+ (`text`/`thinking`/…) into what `web/app.js` consumes (`token`/`thought`/…).
165
+
166
+ ### Model artifacts
167
+ `adapters/` and `models/` hold earlier fine-tuning output. **They are gitignored — not backed up by
168
+ git.** The LoRA adapters are not loaded by default: `behavior_v1` emits a spurious empty
169
+ `<tool_call></tool_call>` as its first output on essentially every prompt, so no tool ever runs, and
170
+ it refuses fully-specified problems; `sft_v2` produces empty output when no tools are exposed and
171
+ string-typed numbers when they are. Both were measured against the base model, which routes and
172
+ formats correctly. `CONTROLAI_ADAPTER=<path>` loads one anyway for A/B work.
173
+
174
+ `configs/*.yaml` are MLX-LoRA training configs and `scripts/` holds the offline pipeline (corpus
175
+ discovery → extraction → dataset generation → training → evaluation). That pipeline is independent
176
+ of the serving path and uses `requirements-training.txt`/`requirements-corpus.txt`.
177
+
178
+ ### Benchmark (`benchmarks/`)
179
+ `controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
180
+ data-hygiene rules (never let benchmark prompts leak into training data; split by `family`, not by
181
+ individual question). `scripts/eval_answer_quality.py` is the fast qualitative check across 18
182
+ domain cases.
README.md CHANGED
@@ -1,12 +1,3 @@
1
- ---
2
- title: ControlAI Agent
3
- colorFrom: blue
4
- colorTo: indigo
5
- sdk: gradio
6
- app_file: app.py
7
- pinned: false
8
- ---
9
-
10
  # ControlAI: Open-Source Safety-Critical AI Agent for Control Systems Engineering
11
 
12
  [![Hugging Face Space](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Space-blue)](https://huggingface.co/spaces/atakankahya/ControlAI-Agent)
@@ -19,23 +10,29 @@ pinned: false
19
 
20
  ---
21
 
22
- ## Quickstart: Run Locally in 2 Steps
23
 
24
- ControlAI runs locally on your workstation without cloud dependencies:
 
 
25
 
26
  ```bash
27
- # 1. Clone the repository
28
  git clone https://github.com/atakankahya/controlai-agent.git
29
  cd controlai-agent
 
30
 
31
- # 2. Launch the application (Automatically opens browser at http://127.0.0.1:8000)
32
- ./run.sh
 
 
33
  ```
34
 
35
- To run in interactive terminal CLI mode instead:
36
- ```bash
37
- ./run.sh --cli
38
- ```
 
 
39
 
40
  ---
41
 
@@ -50,7 +47,13 @@ Standard large language models (LLMs) operate probabilistically without determin
50
  1. **Deterministic Scientific Sandbox:** Computes continuous/discrete algebraic Riccati equations (CARE/DARE), matrix exponentials, and Bode diagrams using LAPACK, SciPy, and CVXPY.
51
  2. **4-Stage Mathematical Proof Standard:** Formulates system class, analytical theorems, closed-form derivations, and engineering breakdown limits.
52
  3. **Dynamic Simulation & Plotting:** Solves nonlinear differential equations and renders verified trajectories directly in the interface.
53
- 4. **Offline RAG Knowledge Engine:** Grounded with 9,900+ chunks indexed across classical and modern control engineering literature.
 
 
 
 
 
 
54
 
55
  ---
56
 
@@ -58,28 +61,27 @@ Standard large language models (LLMs) operate probabilistically without determin
58
 
59
  ```mermaid
60
  graph TD
61
- User([Engineering Query]) --> WebApp[Web Console & CLI]
62
- WebApp --> Orchestrator[ControlAI Agent Orchestrator]
63
-
64
- subgraph Core Engine [Hybrid Verification Engine]
65
- Brain[Foundation Model - Qwen3-4B]
66
- LoRA[Theory & Tool Adapter - SFT]
67
- RAG[Offline Knowledge Engine - 9,900+ Chunks]
68
- Tools[Deterministic Numerical Tool Suite]
69
  end
70
-
71
- Orchestrator --> Brain
72
- Orchestrator --> LoRA
73
- Orchestrator --> RAG
74
- Orchestrator --> Tools
75
-
76
- Tools --> SciPy[SciPy / LAPACK / BLAS Matrix Solvers]
77
- Tools --> PythonControl[Python Control ct.tf / step_response]
78
- Tools --> PyExecutor[Python Simulation Sandbox]
79
- Tools --> Verifier[Numerical Residual Verifier]
80
-
81
- Verifier --> FinalResponse[Verified Technical Synthesis & Plots]
82
- FinalResponse --> WebApp
83
  ```
84
 
85
  ---
@@ -107,6 +109,11 @@ graph TD
107
 
108
  ## Benchmark Results (ControlBench-v1)
109
 
 
 
 
 
 
110
  Evaluated across **50 multi-pillar benchmark problems**:
111
 
112
  | Benchmark Pillar | Qwen3-4B Base (Text-Only) | ControlAI Agent (Ours) | Verification Mechanism |
 
 
 
 
 
 
 
 
 
 
1
  # ControlAI: Open-Source Safety-Critical AI Agent for Control Systems Engineering
2
 
3
  [![Hugging Face Space](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Space-blue)](https://huggingface.co/spaces/atakankahya/ControlAI-Agent)
 
10
 
11
  ---
12
 
13
+ ## Quickstart
14
 
15
+ ControlAI runs entirely on your own machine. Requires **Apple Silicon** (inference is MLX) with
16
+ at least 16 GB of unified memory; the default model needs about 8 GB on disk and is downloaded on
17
+ first run.
18
 
19
  ```bash
 
20
  git clone https://github.com/atakankahya/controlai-agent.git
21
  cd controlai-agent
22
+ pip install -r requirements.txt
23
 
24
+ ./run.sh # web console, opens http://127.0.0.1:8000
25
+ ./run.sh --cli # interactive terminal chat
26
+ ./run.sh --cli "design an LQR for A=[[0,1],[-2,-3]], B=[[0],[1]], Q=eye(2), R=1"
27
+ ./run.sh --build-index # build the dense retrieval index from data/user_docs/ (~45 min)
28
  ```
29
 
30
+ | Variable | Default | Purpose |
31
+ | :-- | :-- | :-- |
32
+ | `CONTROLAI_MODEL` | `mlx-community/Qwen3-14B-4bit` | any MLX model id or local path |
33
+ | `CONTROLAI_ADAPTER` | *(none)* | optional LoRA adapter |
34
+ | `CONTROLAI_THINKING` | `auto` | `off`, `auto` (conceptual questions only), or `on` |
35
+ | `CONTROLAI_THINK_BUDGET` | `512` | ceiling on tokens spent reasoning |
36
 
37
  ---
38
 
 
47
  1. **Deterministic Scientific Sandbox:** Computes continuous/discrete algebraic Riccati equations (CARE/DARE), matrix exponentials, and Bode diagrams using LAPACK, SciPy, and CVXPY.
48
  2. **4-Stage Mathematical Proof Standard:** Formulates system class, analytical theorems, closed-form derivations, and engineering breakdown limits.
49
  3. **Dynamic Simulation & Plotting:** Solves nonlinear differential equations and renders verified trajectories directly in the interface.
50
+ 4. **Offline Retrieval:** 80,000+ chunks of classical and modern control literature, indexed locally.
51
+ The prebuilt index is not distributed -- it carries the full text of copyrighted textbooks --
52
+ so `--build-index` builds one from whatever you put in `data/user_docs/`.
53
+ Lexical and dense rankings are fused and gated on cosine similarity, so an unrelated passage is
54
+ dropped rather than cited — the retriever returning nothing is a normal outcome.
55
+ 5. **Bounded latency:** the fixed system-prompt and tool-schema prefix is prefilled once at startup
56
+ and reused across turns, so answers begin streaming in well under a second.
57
 
58
  ---
59
 
 
61
 
62
  ```mermaid
63
  graph TD
64
+ User([Engineering Query]) --> Frontend[Web Console / CLI]
65
+ Frontend --> Agent[ControlAgent streaming tool loop]
66
+
67
+ subgraph Local[Runs entirely on-device]
68
+ Engine[LocalEngine - MLX, cached KV prefix]
69
+ Model[Qwen3-14B-4bit]
70
+ Retriever[Hybrid Retriever - BM25 + dense]
71
+ Tools[Deterministic Tool Registry]
72
  end
73
+
74
+ Agent --> Engine --> Model
75
+ Agent --> Retriever
76
+ Agent --> Tools
77
+
78
+ Tools --> SciPy[SciPy / LAPACK / BLAS]
79
+ Tools --> PythonControl[python-control]
80
+ Tools --> PyExecutor[Sandboxed Python]
81
+ Tools --> Verifier[Residual Verifier]
82
+
83
+ Verifier --> Answer[Verified answer, plots, citations]
84
+ Answer --> Frontend
 
85
  ```
86
 
87
  ---
 
109
 
110
  ## Benchmark Results (ControlBench-v1)
111
 
112
+ > **These figures were measured against the previous architecture** — the fine-tuned Qwen3-4B LoRA
113
+ > served through the old orchestrator. That serving path has been replaced (base Qwen3-14B, no
114
+ > adapter, rewritten agent loop and retriever), and the suite has not yet been re-run against it, so
115
+ > treat the table as historical rather than as a description of the current build.
116
+
117
  Evaluated across **50 multi-pillar benchmark problems**:
118
 
119
  | Benchmark Pillar | Qwen3-4B Base (Text-Only) | ControlAI Agent (Ours) | Verification Mechanism |
app.py CHANGED
@@ -1,10 +1,21 @@
1
- """ControlAI Web Application Backend (FastAPI)."""
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
  import asyncio
6
  import json
7
- import os
8
  import queue
9
  import shutil
10
  import sys
@@ -15,11 +26,9 @@ from contextlib import asynccontextmanager
15
  from pathlib import Path
16
  from typing import Any
17
 
18
- import gradio as gr
19
- import spaces
20
  from fastapi import FastAPI, File, HTTPException, UploadFile
21
  from fastapi.middleware.cors import CORSMiddleware
22
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
23
  from fastapi.staticfiles import StaticFiles
24
  from pydantic import BaseModel
25
 
@@ -27,56 +36,47 @@ PROJECT_ROOT = Path(__file__).resolve().parent
27
  if str(PROJECT_ROOT) not in sys.path:
28
  sys.path.insert(0, str(PROJECT_ROOT))
29
 
30
- from controlai_agent.orchestrator import HAS_MLX, ControlAIAgent
31
  from controlai_rag.chunker import chunk_document
32
  from controlai_rag.document_loader import load_single_file
33
  from controlai_rag.index import get_shared_index
34
 
35
- # MLX keeps its compute stream in thread-local state: the model must be
36
- # loaded on the exact same OS thread that later runs generation, or MLX
37
- # raises "There is no Stream(cpu, 0) in current thread." Routing every agent
38
- # call through this single dedicated worker thread keeps MLX on one
39
- # consistent thread for the whole process lifetime.
40
- #
41
- # This must NEVER be used for the CUDA/ZeroGPU path: ZeroGPU's `spaces`
42
- # library only intercepts CUDA calls within the exact context it manages
43
- # (the main thread during startup; the request-handling context for
44
- # @spaces.GPU calls). Moving a CUDA-touching call into this manually-created
45
- # thread let a real `torch._C._cuda_init()` leak through outside any
46
- # @spaces.GPU context and crashed the Space on startup:
47
- # "Low-level CUDA init reached. ZeroGPU's PyTorch CUDA emulation mode
48
- # did not intercept a CUDA operation in your code."
49
- # HAS_MLX is only ever True on the machine that actually has mlx_lm
50
- # installed (Apple Silicon) -- never on a HF Spaces Linux/CUDA container --
51
- # so gating on it keeps MLX's fix local while restoring the CUDA/GGUF path
52
- # to calling directly on whatever thread FastAPI/spaces already controls,
53
- # exactly as it worked before.
54
- inference_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="controlai-inference") if HAS_MLX else None
55
- # llama.cpp's Llama object is not safe for concurrent generation calls from
56
- # multiple threads; on the non-MLX path (no dedicated executor serializing
57
- # things for us) a plain lock does that job instead.
58
  inference_lock = threading.Lock()
59
 
 
 
 
 
 
 
 
 
 
60
 
61
- async def _run_inference(fn, *args):
62
- """Run an inference call on the MLX-safe dedicated thread if MLX is in
63
- play, otherwise directly (correct for CUDA/ZeroGPU and GGUF)."""
64
- if inference_executor is not None:
65
- return await asyncio.get_event_loop().run_in_executor(inference_executor, fn, *args)
66
- with inference_lock:
67
- return fn(*args)
68
 
69
 
70
  @asynccontextmanager
71
  async def lifespan(app: FastAPI):
72
- print("Pre-loading ControlAI Core Engine on startup...")
73
- await _run_inference(get_agent)
74
- print("ControlAI Core Engine is online and ready for traffic.")
 
75
  yield
76
 
77
 
78
- app = FastAPI(title="ControlAI", version="1.0.0", lifespan=lifespan)
79
-
80
  app.add_middleware(
81
  CORSMiddleware,
82
  allow_origins=["*"],
@@ -84,56 +84,8 @@ app.add_middleware(
84
  allow_methods=["*"],
85
  allow_headers=["*"],
86
  )
87
-
88
- # Static directories
89
- STATIC_DIR = PROJECT_ROOT / "web"
90
- PLOTS_DIR = PROJECT_ROOT / "outputs" / "plots"
91
- USER_DOCS_DIR = PROJECT_ROOT / "data" / "user_docs"
92
- UPLOADED_DOCS_DIR = USER_DOCS_DIR / "user_uploaded"
93
-
94
- STATIC_DIR.mkdir(parents=True, exist_ok=True)
95
- PLOTS_DIR.mkdir(parents=True, exist_ok=True)
96
- UPLOADED_DOCS_DIR.mkdir(parents=True, exist_ok=True)
97
-
98
- # Mount plots for web rendering
99
  app.mount("/plots", StaticFiles(directory=str(PLOTS_DIR)), name="plots")
100
- if STATIC_DIR.exists():
101
- app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
102
-
103
- # Initialize Agent
104
- agent_instance: ControlAIAgent | None = None
105
-
106
-
107
- def get_agent() -> ControlAIAgent:
108
- global agent_instance
109
- if agent_instance is None:
110
- print("Initializing ControlAI Core Engine...")
111
- agent_instance = ControlAIAgent()
112
- print("ControlAI Core Engine initialized.")
113
- return agent_instance
114
-
115
-
116
- # On ZeroGPU Spaces, actual CUDA work may only happen inside a function
117
- # decorated with @spaces.GPU (it requests physical GPU time for the call and
118
- # releases it afterward). Outside of a ZeroGPU Space this decorator is a
119
- # harmless no-op, so it's safe to always wrap these.
120
- @spaces.GPU(duration=300)
121
- def _run_stream_on_gpu(message: str, history: list[dict[str, str]]):
122
- yield from get_agent().run_stream(message, history=history)
123
-
124
-
125
- @spaces.GPU(duration=300)
126
- def _run_on_gpu(message: str, history: list[dict[str, str]]):
127
- return get_agent().run(message, history=history, verbose=False)
128
-
129
-
130
- # ZeroGPU's startup check statically looks for a @spaces.GPU function wired
131
- # to a Gradio event handler -- must be a module-level function referenced by
132
- # name, not one defined inline inside a `with gr.Blocks():` block.
133
- @spaces.GPU(duration=300)
134
- def _zerogpu_registration_probe(message: str) -> str:
135
- result = get_agent().run(message or "hi", history=[], verbose=False)
136
- return result.final_response
137
 
138
 
139
  class ChatRequest(BaseModel):
@@ -148,239 +100,211 @@ class ChatResponse(BaseModel):
148
  elapsed_seconds: float
149
 
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  @app.get("/", response_class=HTMLResponse)
152
  async def serve_index() -> HTMLResponse:
153
  index_file = STATIC_DIR / "index.html"
154
- if index_file.exists():
155
- return HTMLResponse(
156
- content=index_file.read_text(encoding="utf-8"),
157
- headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"},
158
- )
159
- return HTMLResponse("<h1>ControlAI Web UI initializing...</h1>")
160
 
161
 
162
  @app.get("/api/status")
163
- async def get_status() -> dict[str, Any]:
164
- rag_index_path = PROJECT_ROOT / "data" / "rag_index"
165
- chunk_count = 0
166
- if rag_index_path.exists():
167
- chunks_file = rag_index_path / "chunks.json"
168
- if chunks_file.exists():
169
- try:
170
- data = json.loads(chunks_file.read_text(encoding="utf-8"))
171
- chunk_count = len(data)
172
- except Exception:
173
- pass
174
-
175
- return {
176
  "system": "ControlAI",
177
- "status": "ready",
178
- "indexed_chunks": chunk_count,
179
  }
 
 
 
 
 
 
 
 
180
 
181
 
182
  @app.get("/api/documents")
183
  async def list_documents() -> dict[str, Any]:
184
  categories: dict[str, list[str]] = {}
185
- if USER_DOCS_DIR.exists():
186
- for item in USER_DOCS_DIR.iterdir():
 
187
  if item.is_dir() and not item.name.startswith("."):
188
- cat_name = item.name.replace("_", " ").title()
189
  files = [f.name for f in item.rglob("*.pdf") if not f.name.startswith(".")]
190
  if files:
191
- categories[cat_name] = files[:10] # Show up to 10 per category
192
  return {"categories": categories}
193
 
194
 
195
  @app.post("/api/upload")
196
  async def upload_document(file: UploadFile = File(...)) -> dict[str, Any]:
197
- filename = file.filename
198
- if not filename:
199
  raise HTTPException(status_code=400, detail="Invalid filename")
200
-
201
- ext = Path(filename).suffix.lower()
202
- if ext not in (".pdf", ".txt", ".md"):
203
  raise HTTPException(status_code=400, detail="Only PDF, TXT, and Markdown files are supported.")
204
 
205
- target_path = UPLOADED_DOCS_DIR / filename
206
- with target_path.open("wb") as f:
207
- shutil.copyfileobj(file.file, f)
208
 
209
- # Ingest directly into RAG index
210
- try:
211
- pages = load_single_file(target_path)
212
- new_chunks = []
213
- for p in pages:
214
- chunks = chunk_document(p)
215
- new_chunks.extend(chunks)
216
-
217
- # Mutating the shared index makes the upload live for the running
218
- # agent immediately -- a fresh instance would only persist to disk and
219
- # stay invisible until restart.
220
  index = get_shared_index()
221
  index.add_chunks(new_chunks)
222
-
 
 
 
 
 
 
223
  return {
224
  "status": "success",
225
- "filename": filename,
226
  "pages_parsed": len(pages),
227
  "chunks_added": len(new_chunks),
 
228
  "total_indexed_chunks": len(index.chunks),
229
  }
230
- except Exception as exc:
231
- raise HTTPException(status_code=500, detail=f"Indexing failed: {str(exc)}")
232
-
233
 
234
- from fastapi.responses import StreamingResponse
 
 
 
 
235
 
236
 
237
  @app.post("/api/chat/stream")
238
- async def chat_stream_endpoint(req: ChatRequest):
239
- if not req.message.strip():
 
240
  raise HTTPException(status_code=400, detail="Message cannot be empty")
241
 
242
- message = req.message.strip()
243
- history = req.history
244
-
245
- if inference_executor is not None:
246
- # MLX path: a persistent dedicated thread is required (see the
247
- # inference_executor comment above), so bridge it into the async
248
- # response via a queue -- Starlette's own rotating threadpool would
249
- # violate MLX's single-thread requirement.
250
- event_queue: queue.Queue = queue.Queue()
251
- _DONE = object()
252
-
253
- def _produce() -> None:
254
- try:
255
- with inference_lock:
256
- for event in _run_stream_on_gpu(message, history):
257
- event_queue.put(event)
258
- except Exception as exc:
259
- event_queue.put({"type": "error", "error": str(exc)})
260
- finally:
261
- event_queue.put(_DONE)
262
-
263
- async def event_generator():
264
- loop = asyncio.get_event_loop()
265
- loop.run_in_executor(inference_executor, _produce)
266
- while True:
267
- # Draining the queue never touches MLX, so this can safely
268
- # run on the default threadpool.
269
- event = await loop.run_in_executor(None, event_queue.get)
270
- if event is _DONE:
271
- break
272
- yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
273
- else:
274
- # CUDA/ZeroGPU/GGUF path: a plain sync generator handed directly to
275
- # StreamingResponse, exactly as this ran before the MLX fix existed.
276
- # Starlette wraps this in its own threadpool (iterate_in_threadpool),
277
- # which -- unlike a manually created ThreadPoolExecutor -- is a
278
- # context ZeroGPU's CUDA interception correctly recognizes.
279
- def event_generator():
280
- try:
281
- with inference_lock:
282
- for event in _run_stream_on_gpu(message, history):
283
- yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
284
- except Exception as exc:
285
- yield f"data: {json.dumps({'type': 'error', 'error': str(exc)}, ensure_ascii=False)}\n\n"
286
 
287
  return StreamingResponse(
288
- event_generator(),
289
  media_type="text/event-stream",
290
- headers={
291
- "Cache-Control": "no-cache",
292
- "Connection": "keep-alive",
293
- "X-Accel-Buffering": "no",
294
- },
295
  )
296
 
297
 
298
  @app.post("/api/chat", response_model=ChatResponse)
299
- async def chat_endpoint(req: ChatRequest) -> ChatResponse:
300
- if not req.message.strip():
 
301
  raise HTTPException(status_code=400, detail="Message cannot be empty")
302
 
303
- t0 = time.time()
304
- try:
305
- result = await _run_inference(_run_on_gpu, req.message.strip(), req.history)
306
- elapsed = time.time() - t0
307
-
308
- # Collect tool traces
309
- traces = []
310
- plots = []
311
- for t in result.tool_traces:
312
- trace_data = {
313
- "tool": t.tool_name,
314
- "args": t.arguments,
315
- "status": t.result.get("status", "success"),
316
- "residual": t.result.get("residual"),
317
- }
318
- traces.append(trace_data)
319
 
320
- # Check if plot was generated by any tool
321
- if "plot_path" in t.result:
322
- p_path = Path(t.result["plot_path"])
323
- if p_path.exists():
324
- plots.append(f"/plots/{p_path.name}")
325
 
326
- return ChatResponse(
327
- response=result.final_response,
328
- tool_traces=traces,
329
- plots=plots,
330
- elapsed_seconds=round(elapsed, 2),
331
- )
332
  except Exception as exc:
333
- elapsed = time.time() - t0
334
- print(f"Chat execution error: {exc}")
335
  return ChatResponse(
336
- response=f"Computation error during inference: {str(exc)}",
337
- tool_traces=[],
338
- plots=[],
339
- elapsed_seconds=round(elapsed, 2),
340
  )
341
 
342
-
343
- # A minimal Gradio Blocks app exists purely so this Space is recognized as a
344
- # Gradio SDK app -- required for ZeroGPU hardware -- and so the @spaces.GPU
345
- # registration probe below is wired to a real Gradio event handler ("No
346
- # @spaces.GPU function detected" otherwise). It is launched on its own
347
- # internal port rather than mounted into our FastAPI app: mounting it and
348
- # then binding our own uvicorn to the same public port collided with
349
- # Gradio's own server startup ("address already in use"). The real UI and
350
- # API are still served entirely by our own FastAPI routes above, on the
351
- # public port.
352
- _gpu_demo = gr.Blocks()
353
- with _gpu_demo:
354
- gr.Markdown("ControlAI is running. Visit the Space's root URL for the full app.")
355
- _probe_in = gr.Textbox(visible=False)
356
- _probe_out = gr.Textbox(visible=False)
357
- _probe_btn = gr.Button(visible=False)
358
- _probe_btn.click(fn=_zerogpu_registration_probe, inputs=_probe_in, outputs=_probe_out)
359
 
360
 
361
  def main() -> None:
362
- import uvicorn
363
-
364
- if os.environ.get("SPACE_ID"):
365
- port = int(os.environ.get("PORT", 7860))
366
- _gpu_demo.launch(server_name="0.0.0.0", server_port=port + 1, prevent_thread_lock=True, show_error=False, quiet=True)
367
- print(f"ControlAI Web UI running on Hugging Face Spaces (port {port})...")
368
- uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
369
- return
370
-
371
- import threading
372
  import webbrowser
373
 
374
- def _open_browser() -> None:
375
- time.sleep(1.2)
 
 
376
  try:
377
  webbrowser.open("http://127.0.0.1:8000")
378
  except Exception:
379
  pass
380
 
381
- threading.Thread(target=_open_browser, daemon=True).start()
382
- print("ControlAI Web UI running at: http://127.0.0.1:8000 (Opening browser...)")
383
- uvicorn.run("app:app", host="127.0.0.1", port=8000, reload=True, log_level="info")
 
384
 
385
 
386
  if __name__ == "__main__":
 
1
+ """ControlAI web server.
2
+
3
+ Local-only by design. The previous version carried a Hugging Face Spaces
4
+ deployment inside it -- Gradio blocks that existed purely to satisfy a ZeroGPU
5
+ SDK check, `@spaces.GPU` decorators, a CUDA/GGUF branch, and a threading split
6
+ whose two halves each existed to work around the other environment. None of it
7
+ ran on this machine, and all of it had to be reasoned about on every change.
8
+ What is left is a FastAPI app serving one MLX-backed agent.
9
+
10
+ MLX keeps its compute stream in thread-local state: the model must be used from
11
+ the same OS thread that loaded it, or it raises "There is no Stream(gpu, 0) in
12
+ current thread." Every call therefore goes through one dedicated worker thread.
13
+ """
14
 
15
  from __future__ import annotations
16
 
17
  import asyncio
18
  import json
 
19
  import queue
20
  import shutil
21
  import sys
 
26
  from pathlib import Path
27
  from typing import Any
28
 
 
 
29
  from fastapi import FastAPI, File, HTTPException, UploadFile
30
  from fastapi.middleware.cors import CORSMiddleware
31
+ from fastapi.responses import HTMLResponse, StreamingResponse
32
  from fastapi.staticfiles import StaticFiles
33
  from pydantic import BaseModel
34
 
 
36
  if str(PROJECT_ROOT) not in sys.path:
37
  sys.path.insert(0, str(PROJECT_ROOT))
38
 
39
+ from controlai_agent.agent import ControlAgent
40
  from controlai_rag.chunker import chunk_document
41
  from controlai_rag.document_loader import load_single_file
42
  from controlai_rag.index import get_shared_index
43
 
44
+ STATIC_DIR = PROJECT_ROOT / "web"
45
+ PLOTS_DIR = PROJECT_ROOT / "outputs" / "plots"
46
+ UPLOADS_DIR = PROJECT_ROOT / "data" / "user_docs" / "user_uploaded"
47
+ for directory in (STATIC_DIR, PLOTS_DIR, UPLOADS_DIR):
48
+ directory.mkdir(parents=True, exist_ok=True)
49
+
50
+ # One thread, for the lifetime of the process: see the module docstring.
51
+ inference_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="controlai")
52
+ # The agent holds a single KV cache that every turn mutates, so turns must not
53
+ # interleave even though they all land on the same thread.
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  inference_lock = threading.Lock()
55
 
56
+ _agent: ControlAgent | None = None
57
+
58
+
59
+ def get_agent() -> ControlAgent:
60
+ global _agent
61
+ if _agent is None:
62
+ _agent = ControlAgent()
63
+ return _agent
64
+
65
 
66
+ async def _on_inference_thread(fn, *args):
67
+ return await asyncio.get_running_loop().run_in_executor(inference_executor, fn, *args)
 
 
 
 
 
68
 
69
 
70
  @asynccontextmanager
71
  async def lifespan(app: FastAPI):
72
+ started = time.time()
73
+ print("Loading ControlAI…")
74
+ await _on_inference_thread(get_agent)
75
+ print(f"ControlAI ready in {time.time() - started:.1f}s -> http://127.0.0.1:8000")
76
  yield
77
 
78
 
79
+ app = FastAPI(title="ControlAI", version="2.0.0", lifespan=lifespan)
 
80
  app.add_middleware(
81
  CORSMiddleware,
82
  allow_origins=["*"],
 
84
  allow_methods=["*"],
85
  allow_headers=["*"],
86
  )
 
 
 
 
 
 
 
 
 
 
 
 
87
  app.mount("/plots", StaticFiles(directory=str(PLOTS_DIR)), name="plots")
88
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
 
91
  class ChatRequest(BaseModel):
 
100
  elapsed_seconds: float
101
 
102
 
103
+ def _to_wire_events(message: str, history: list[dict[str, str]]):
104
+ """Translate agent events into the shape the browser client consumes.
105
+
106
+ The client speaks `thought`/`token`/`tool_end`/`plot`/`done`; the agent
107
+ speaks `thinking`/`text`/... Doing the mapping here keeps the wire format
108
+ stable for the existing UI while the agent's own vocabulary stays clean.
109
+ """
110
+ thoughts: list[str] = []
111
+ for event in get_agent().stream(message, history):
112
+ kind = event["type"]
113
+ if kind == "text":
114
+ yield {"type": "token", "content": event["text"]}
115
+ elif kind == "thinking":
116
+ yield {"type": "thought", "content": event["text"]}
117
+ elif kind == "tool_start":
118
+ note = f"Calling {event['tool']} with {json.dumps(event['arguments'], ensure_ascii=False, default=str)[:300]}"
119
+ thoughts.append(note)
120
+ yield {"type": "thought", "content": note}
121
+ yield {"type": "tool_start", "tool": event["tool"], "args": event["arguments"]}
122
+ elif kind == "tool_end":
123
+ trace = {"tool": event["tool"], "status": event["status"]}
124
+ note = f"{event['tool']} -> {event['status']}"
125
+ thoughts.append(note)
126
+ yield {"type": "thought", "content": note}
127
+ yield {"type": "tool_end", "trace": trace}
128
+ elif kind == "plot":
129
+ yield {"type": "plot", "url": event["url"]}
130
+ elif kind == "done":
131
+ yield {
132
+ "type": "done",
133
+ "response": event["answer"],
134
+ "traces": event["traces"],
135
+ "plots": event["plots"],
136
+ "sources": event["sources"],
137
+ "thoughts": thoughts,
138
+ "stats": event["stats"],
139
+ }
140
+
141
+
142
  @app.get("/", response_class=HTMLResponse)
143
  async def serve_index() -> HTMLResponse:
144
  index_file = STATIC_DIR / "index.html"
145
+ if not index_file.exists():
146
+ return HTMLResponse("<h1>ControlAI: web/index.html is missing</h1>", status_code=500)
147
+ return HTMLResponse(
148
+ index_file.read_text(encoding="utf-8"),
149
+ headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
150
+ )
151
 
152
 
153
  @app.get("/api/status")
154
+ async def status() -> dict[str, Any]:
155
+ index = get_shared_index()
156
+ agent = _agent
157
+ payload: dict[str, Any] = {
 
 
 
 
 
 
 
 
 
158
  "system": "ControlAI",
159
+ "status": "ready" if agent else "loading",
160
+ "indexed_chunks": len(index.chunks),
161
  }
162
+ if agent:
163
+ payload.update(
164
+ model=agent.engine.model_id,
165
+ adapter=agent.engine.adapter_path,
166
+ thinking=agent.thinking,
167
+ dense_retrieval=bool(agent.retriever and agent.retriever.has_dense),
168
+ )
169
+ return payload
170
 
171
 
172
  @app.get("/api/documents")
173
  async def list_documents() -> dict[str, Any]:
174
  categories: dict[str, list[str]] = {}
175
+ root = UPLOADS_DIR.parent
176
+ if root.exists():
177
+ for item in sorted(root.iterdir()):
178
  if item.is_dir() and not item.name.startswith("."):
 
179
  files = [f.name for f in item.rglob("*.pdf") if not f.name.startswith(".")]
180
  if files:
181
+ categories[item.name.replace("_", " ").title()] = files[:10]
182
  return {"categories": categories}
183
 
184
 
185
  @app.post("/api/upload")
186
  async def upload_document(file: UploadFile = File(...)) -> dict[str, Any]:
187
+ if not file.filename:
 
188
  raise HTTPException(status_code=400, detail="Invalid filename")
189
+ if Path(file.filename).suffix.lower() not in (".pdf", ".txt", ".md"):
 
 
190
  raise HTTPException(status_code=400, detail="Only PDF, TXT, and Markdown files are supported.")
191
 
192
+ target = UPLOADS_DIR / Path(file.filename).name
193
+ with target.open("wb") as handle:
194
+ shutil.copyfileobj(file.file, handle)
195
 
196
+ def _ingest() -> dict[str, Any]:
197
+ pages = load_single_file(target)
198
+ new_chunks = [c for page in pages for c in chunk_document(page)]
 
 
 
 
 
 
 
 
199
  index = get_shared_index()
200
  index.add_chunks(new_chunks)
201
+ # Embedding happens on the inference thread because it uses MLX too.
202
+ embedded = 0
203
+ agent = get_agent()
204
+ if agent.retriever is not None:
205
+ embedded = agent.retriever.add_chunks(
206
+ [c for c in index.chunks if c["chunk_id"] in {n["chunk_id"] for n in new_chunks}]
207
+ )
208
  return {
209
  "status": "success",
210
+ "filename": target.name,
211
  "pages_parsed": len(pages),
212
  "chunks_added": len(new_chunks),
213
+ "chunks_embedded": embedded,
214
  "total_indexed_chunks": len(index.chunks),
215
  }
 
 
 
216
 
217
+ try:
218
+ with inference_lock:
219
+ return await _on_inference_thread(_ingest)
220
+ except Exception as exc:
221
+ raise HTTPException(status_code=500, detail=f"Indexing failed: {exc}") from exc
222
 
223
 
224
  @app.post("/api/chat/stream")
225
+ async def chat_stream(req: ChatRequest) -> StreamingResponse:
226
+ message = req.message.strip()
227
+ if not message:
228
  raise HTTPException(status_code=400, detail="Message cannot be empty")
229
 
230
+ events: queue.Queue = queue.Queue()
231
+ sentinel = object()
232
+
233
+ def produce() -> None:
234
+ try:
235
+ with inference_lock:
236
+ for event in _to_wire_events(message, req.history):
237
+ events.put(event)
238
+ except Exception as exc: # surface the failure in the chat, don't hang
239
+ print(f"[chat] {type(exc).__name__}: {exc}")
240
+ events.put({"type": "error", "error": f"{type(exc).__name__}: {exc}"})
241
+ finally:
242
+ events.put(sentinel)
243
+
244
+ async def relay():
245
+ loop = asyncio.get_running_loop()
246
+ loop.run_in_executor(inference_executor, produce)
247
+ while True:
248
+ # Draining the queue never touches MLX, so the default threadpool
249
+ # is fine here and keeps the single inference thread free.
250
+ event = await loop.run_in_executor(None, events.get)
251
+ if event is sentinel:
252
+ break
253
+ yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
  return StreamingResponse(
256
+ relay(),
257
  media_type="text/event-stream",
258
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
 
 
 
 
259
  )
260
 
261
 
262
  @app.post("/api/chat", response_model=ChatResponse)
263
+ async def chat(req: ChatRequest) -> ChatResponse:
264
+ message = req.message.strip()
265
+ if not message:
266
  raise HTTPException(status_code=400, detail="Message cannot be empty")
267
 
268
+ started = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
+ def _run():
271
+ with inference_lock:
272
+ return get_agent().run(message, req.history)
 
 
273
 
274
+ try:
275
+ result = await _on_inference_thread(_run)
 
 
 
 
276
  except Exception as exc:
277
+ print(f"[chat] {type(exc).__name__}: {exc}")
 
278
  return ChatResponse(
279
+ response=f"Inference failed: {exc}",
280
+ elapsed_seconds=round(time.time() - started, 2),
 
 
281
  )
282
 
283
+ return ChatResponse(
284
+ response=result.answer,
285
+ tool_traces=[{"tool": t.name, "status": t.status} for t in result.traces],
286
+ plots=result.plots,
287
+ elapsed_seconds=round(time.time() - started, 2),
288
+ )
 
 
 
 
 
 
 
 
 
 
 
289
 
290
 
291
  def main() -> None:
292
+ import threading as _threading
 
 
 
 
 
 
 
 
 
293
  import webbrowser
294
 
295
+ import uvicorn
296
+
297
+ def open_browser() -> None:
298
+ time.sleep(1.5)
299
  try:
300
  webbrowser.open("http://127.0.0.1:8000")
301
  except Exception:
302
  pass
303
 
304
+ _threading.Thread(target=open_browser, daemon=True).start()
305
+ # No reload: the model load is far too expensive to repeat on every file
306
+ # save, and reload would fork a second copy of it.
307
+ uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")
308
 
309
 
310
  if __name__ == "__main__":
cli.py CHANGED
@@ -1,85 +1,114 @@
1
  #!/usr/bin/env python3
2
- """Interactive CLI for ControlAI Agent with deterministic tool calling."""
3
 
4
  from __future__ import annotations
5
 
6
  import argparse
7
  import sys
 
8
  from pathlib import Path
9
 
10
  PROJECT_ROOT = Path(__file__).resolve().parent
11
  if str(PROJECT_ROOT) not in sys.path:
12
  sys.path.insert(0, str(PROJECT_ROOT))
13
 
14
- from controlai_agent.orchestrator import ControlAIAgent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  def main() -> int:
18
- parser = argparse.ArgumentParser(description="ControlAI Offline Engineering Agent CLI")
19
- parser.add_argument(
20
- "--model",
21
- type=str,
22
- default="mlx-community/Qwen3-4B-Instruct-2507-4bit",
23
- help="Base model path or HuggingFace repo",
24
- )
25
  parser.add_argument(
26
- "--adapter-path",
27
- type=str,
28
  default=None,
29
- help="Optional fine-tuned LoRA adapter path",
30
- )
31
- parser.add_argument(
32
- "--prompt",
33
- type=str,
34
- default=None,
35
- help="Single prompt to execute in non-interactive mode",
36
- )
37
- parser.add_argument(
38
- "--verbose",
39
- action="store_true",
40
- help="Print raw tool calls and intermediate steps",
41
  )
 
 
42
  args = parser.parse_args()
43
 
44
- print("Initializing ControlAI Agent...")
45
- agent = ControlAIAgent(model_path=args.model, adapter_path=args.adapter_path)
46
- print("ControlAI Agent initialized with deterministic control tools.")
 
 
 
 
 
 
47
 
48
  if args.prompt:
49
- result = agent.run(args.prompt, verbose=args.verbose)
50
- print("\n" + "=" * 50)
51
- print("AGENT RESPONSE:")
52
- print("=" * 50)
53
- print(result.final_response)
54
- if result.tool_traces:
55
- print("\n" + "-" * 50)
56
- print(f"EXECUTED TOOLS ({len(result.tool_traces)}):")
57
- for trace in result.tool_traces:
58
- print(f" * {trace.tool_name}({trace.arguments}) -> {trace.result.get('status', 'done')}")
59
  return 0
60
 
61
- print("\n" + "=" * 60)
62
- print("🤖 Welcome to Control-LLM (Offline Control Engineering Agent)")
63
- print("=" * 60)
64
- print("Type your control engineering question or design problem.")
65
- print("Commands: 'exit' or 'quit' to end, 'verbose' to toggle tool details.\n")
66
  while True:
67
  try:
68
- user_input = input("\n[Engineer] > ").strip()
69
- if not user_input:
70
- continue
71
- if user_input.lower() in ("exit", "quit", "q"):
72
- break
73
- result = agent.run(user_input, verbose=args.verbose)
74
- print(f"\n[ControlAI]\n{result.final_response}")
75
- if result.tool_traces and not args.verbose:
76
- tools_used = ", ".join(t.tool_name for t in result.tool_traces)
77
- print(f"\n(Verified using tools: {tools_used})")
78
  except (KeyboardInterrupt, EOFError):
79
- print("\nExiting ControlAI.")
80
- break
81
-
82
- return 0
 
 
 
 
 
 
 
 
 
 
 
83
 
84
 
85
  if __name__ == "__main__":
 
1
  #!/usr/bin/env python3
2
+ """Interactive terminal client for ControlAI."""
3
 
4
  from __future__ import annotations
5
 
6
  import argparse
7
  import sys
8
+ import time
9
  from pathlib import Path
10
 
11
  PROJECT_ROOT = Path(__file__).resolve().parent
12
  if str(PROJECT_ROOT) not in sys.path:
13
  sys.path.insert(0, str(PROJECT_ROOT))
14
 
15
+ from controlai_agent.agent import ControlAgent
16
+ from controlai_agent.engine import DEFAULT_ADAPTER, DEFAULT_MODEL, LocalEngine
17
+
18
+ DIM, RESET, BOLD = "\033[2m", "\033[0m", "\033[1m"
19
+
20
+
21
+ def converse(agent: ControlAgent, question: str, history: list[dict], verbose: bool) -> str:
22
+ started = time.time()
23
+ first_text: float | None = None
24
+ answer = ""
25
+ in_thought = False
26
+
27
+ for event in agent.stream(question, history):
28
+ kind = event["type"]
29
+ if kind == "thinking":
30
+ if not in_thought:
31
+ print(f"{DIM}thinking… ", end="", flush=True)
32
+ in_thought = True
33
+ if verbose:
34
+ print(f"{DIM}{event['text']}{RESET}", end="", flush=True)
35
+ elif kind == "text":
36
+ if in_thought:
37
+ print(RESET, flush=True)
38
+ in_thought = False
39
+ if first_text is None:
40
+ first_text = time.time() - started
41
+ print(event["text"], end="", flush=True)
42
+ elif kind == "tool_start":
43
+ args = str(event["arguments"])
44
+ print(f"\n{DIM}→ {event['tool']}({args[:100]}{'…' if len(args) > 100 else ''}){RESET}", flush=True)
45
+ elif kind == "tool_end":
46
+ print(f"{DIM} {event['status']}{RESET}", flush=True)
47
+ elif kind == "plot":
48
+ print(f"\n{DIM}[plot saved: outputs/plots/{Path(event['url']).name}]{RESET}", flush=True)
49
+ elif kind == "done":
50
+ answer = event["answer"]
51
+ if event["sources"]:
52
+ print(f"\n\n{DIM}Sources: {'; '.join(dict.fromkeys(event['sources']))}{RESET}")
53
+ stats = event["stats"]
54
+ print(
55
+ f"\n{DIM}{time.time() - started:.1f}s total · first token {first_text or 0:.2f}s · "
56
+ f"{stats['cached_tokens']}/{stats['prompt_tokens']} prompt tokens cached · "
57
+ f"{stats['decode_tps']} tok/s{RESET}"
58
+ )
59
+ return answer
60
 
61
 
62
  def main() -> int:
63
+ parser = argparse.ArgumentParser(description="ControlAI -- offline control engineering agent")
64
+ parser.add_argument("prompt", nargs="*", help="ask one question and exit")
65
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="MLX model id or local path")
66
+ parser.add_argument("--adapter", default=DEFAULT_ADAPTER, help="optional LoRA adapter path")
 
 
 
67
  parser.add_argument(
68
+ "--thinking",
69
+ choices=("off", "auto", "on"),
70
  default=None,
71
+ help="reasoning before answering (default: auto -- on for conceptual questions only)",
 
 
 
 
 
 
 
 
 
 
 
72
  )
73
+ parser.add_argument("--think-budget", type=int, default=None, help="max tokens spent reasoning")
74
+ parser.add_argument("--verbose", action="store_true", help="print reasoning and full tool output")
75
  args = parser.parse_args()
76
 
77
+ print(f"Loading {args.model}…")
78
+ engine = LocalEngine(model_id=args.model, adapter_path=args.adapter)
79
+ kwargs = {}
80
+ if args.thinking:
81
+ kwargs["thinking"] = args.thinking
82
+ if args.think_budget:
83
+ kwargs["think_budget"] = args.think_budget
84
+ agent = ControlAgent(engine=engine, **kwargs)
85
+ print(f"Ready in {engine.load_seconds:.1f}s.\n")
86
 
87
  if args.prompt:
88
+ converse(agent, " ".join(args.prompt), [], args.verbose)
 
 
 
 
 
 
 
 
 
89
  return 0
90
 
91
+ print(f"{BOLD}ControlAI{RESET} control engineering assistant. Ctrl-D or 'exit' to quit.")
92
+ history: list[dict] = []
 
 
 
93
  while True:
94
  try:
95
+ question = input(f"\n{BOLD}you ›{RESET} ").strip()
 
 
 
 
 
 
 
 
 
96
  except (KeyboardInterrupt, EOFError):
97
+ print("\nBye.")
98
+ return 0
99
+ if not question:
100
+ continue
101
+ if question.lower() in ("exit", "quit", "q"):
102
+ return 0
103
+ if question.lower() in ("clear", "reset"):
104
+ history.clear()
105
+ agent.engine.reset_cache()
106
+ print(f"{DIM}history cleared{RESET}")
107
+ continue
108
+ print()
109
+ answer = converse(agent, question, history, args.verbose)
110
+ history.append({"role": "user", "content": question})
111
+ history.append({"role": "assistant", "content": answer})
112
 
113
 
114
  if __name__ == "__main__":
controlai_agent/agent.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The ControlAI agent loop.
2
+
3
+ Replaces the previous `orchestrator.py`. The differences that matter:
4
+
5
+ * **It streams.** Text reaches the caller as the model produces it. The old
6
+ loop blocked for a full generation and then re-emitted the finished string
7
+ word by word, which looked like streaming but meant the user waited for the
8
+ entire answer before seeing anything.
9
+ * **It reuses the KV cache** across tool steps via `LocalEngine`, so the
10
+ multi-thousand-token tool-schema prefix is prefilled once per process
11
+ rather than once per step.
12
+ * **It trusts the model with parameters.** The old loop ran a "provenance"
13
+ check that refused any matrix it could not trace back to the user's text.
14
+ That blocked the most useful thing the agent does -- working an example the
15
+ user asked for -- so it is gone. What remains is schema validation and
16
+ execution in `registry.execute`, which are real guarantees, plus a
17
+ repetition guard for genuinely degenerate output.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Generator, Iterable, Sequence
28
+
29
+ from controlai_agent import toolcall
30
+ from controlai_agent.engine import LocalEngine, SamplingConfig
31
+ from controlai_agent.prompts import RETRIEVAL_PREAMBLE, SYNTHESIS_NUDGE, SYSTEM_PROMPT
32
+ from controlai_agent.registry import registry
33
+ from controlai_agent.toolcall import ToolCall
34
+
35
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
36
+
37
+ MAX_HISTORY_TOKENS = 8000
38
+ # Two rounds of tools answers essentially every real question (design, then
39
+ # simulate). The old default of four mostly bought extra latency and gave a
40
+ # stuck model more room to loop.
41
+ MAX_TOOL_STEPS = 2
42
+ MAX_CALLS_PER_TOOL = 2
43
+
44
+ THINKING_MODE = os.environ.get("CONTROLAI_THINKING", "auto").lower()
45
+ THINK_BUDGET = int(os.environ.get("CONTROLAI_THINK_BUDGET", "512"))
46
+
47
+ # Questions that are about a concept rather than a specific system. Used only
48
+ # to decide whether to spend thinking tokens -- never to block a tool call.
49
+ _CONCEPTUAL_RE = re.compile(
50
+ r"\b(why|explain|what is|what are|difference between|compare|derive|"
51
+ r"derivation|prove|proof|intuition|when should|trade-?off|meaning of)\b",
52
+ re.IGNORECASE,
53
+ )
54
+
55
+
56
+ @dataclass
57
+ class ToolTrace:
58
+ name: str
59
+ arguments: dict[str, Any]
60
+ result: dict[str, Any]
61
+
62
+ @property
63
+ def status(self) -> str:
64
+ return str(self.result.get("status", "success"))
65
+
66
+
67
+ @dataclass
68
+ class AgentResult:
69
+ answer: str
70
+ traces: list[ToolTrace] = field(default_factory=list)
71
+ plots: list[str] = field(default_factory=list)
72
+ sources: list[str] = field(default_factory=list)
73
+ stats: dict[str, Any] = field(default_factory=dict)
74
+
75
+
76
+ class _StreamGate:
77
+ """Emits streamed text while withholding anything from `marker` onward.
78
+
79
+ The model decides between answering and calling a tool by what it emits
80
+ first, and that decision is only visible partway through a token. This
81
+ releases text as soon as it cannot be the start of `marker`, so prose
82
+ streams with no perceptible delay while a tool call never leaks into the
83
+ chat.
84
+ """
85
+
86
+ def __init__(self, marker: str = "<tool_call>") -> None:
87
+ self.marker = marker
88
+ self._pending = ""
89
+ self.suppressed = False
90
+
91
+ def feed(self, text: str) -> str:
92
+ if self.suppressed:
93
+ return ""
94
+ self._pending += text
95
+ idx = self._pending.find(self.marker)
96
+ if idx != -1:
97
+ out, self._pending, self.suppressed = self._pending[:idx], "", True
98
+ return out
99
+ # Hold back only a possible partial marker at the very end.
100
+ hold = 0
101
+ for n in range(min(len(self.marker) - 1, len(self._pending)), 0, -1):
102
+ if self._pending.endswith(self.marker[:n]):
103
+ hold = n
104
+ break
105
+ out, self._pending = (self._pending[:-hold], self._pending[-hold:]) if hold else (self._pending, "")
106
+ return out
107
+
108
+ def flush(self) -> str:
109
+ if self.suppressed:
110
+ return ""
111
+ out, self._pending = self._pending, ""
112
+ return out
113
+
114
+
115
+ class ControlAgent:
116
+ """Control-engineering agent over a local model and deterministic tools."""
117
+
118
+ def __init__(
119
+ self,
120
+ engine: LocalEngine | None = None,
121
+ tool_registry=registry,
122
+ retriever: Any | None = None,
123
+ max_tool_steps: int = MAX_TOOL_STEPS,
124
+ thinking: str = THINKING_MODE,
125
+ think_budget: int = THINK_BUDGET,
126
+ ) -> None:
127
+ import controlai_agent.tools # noqa: F401 -- registers every tool
128
+
129
+ self.engine = engine or LocalEngine()
130
+ self.registry = tool_registry
131
+ self.max_tool_steps = max_tool_steps
132
+ self.thinking = thinking
133
+ self.think_budget = think_budget
134
+ self.tool_schemas = self.registry.get_tool_schemas()
135
+
136
+ if retriever is None:
137
+ try:
138
+ from controlai_rag.retriever import get_retriever
139
+
140
+ retriever = get_retriever()
141
+ except Exception as exc: # retrieval is an enhancement, not a dependency
142
+ print(f"[agent] retrieval unavailable ({type(exc).__name__}: {exc}); continuing without it")
143
+ self.retriever = retriever
144
+
145
+ self._prewarm()
146
+
147
+ # ------------------------------------------------------------- setup
148
+
149
+ def _prewarm(self) -> None:
150
+ """Prefill the fixed system-prompt-plus-tool-schema prefix.
151
+
152
+ Everything after it in a real prompt is conversation, so this is the
153
+ one part of every request that is byte-identical every time. Paying for
154
+ it at startup is what makes the first question feel instant.
155
+ """
156
+ prefix = self.engine.render(
157
+ [{"role": "system", "content": SYSTEM_PROMPT}], tools=self.tool_schemas
158
+ )
159
+ # Cut at the end of the system block: the generation prompt that
160
+ # `render` appends belongs to the user's turn, not to the prefix.
161
+ anchor = prefix.rfind("<|im_end|>")
162
+ if anchor != -1:
163
+ prefix = prefix[: anchor + len("<|im_end|>\n")]
164
+ n = self.engine.prewarm(prefix)
165
+ print(f"[agent] prewarmed {n} prefix tokens ({self.engine.model_id})")
166
+
167
+ # --------------------------------------------------------- prompting
168
+
169
+ def _retrieve(self, question: str) -> tuple[str, list[str]]:
170
+ if self.retriever is None:
171
+ return "", []
172
+ try:
173
+ hits = self.retriever.search(question, top_k=4)
174
+ except Exception as exc:
175
+ print(f"[agent] retrieval failed ({type(exc).__name__}: {exc})")
176
+ return "", []
177
+ if not hits:
178
+ return "", []
179
+ blocks, labels = [], []
180
+ for hit in hits:
181
+ label = hit.get("label") or hit.get("source_name") or "Reference"
182
+ page = hit.get("page")
183
+ label = f"{label}, p. {page}" if page else label
184
+ text = " ".join(str(hit.get("text", "")).split())[:800]
185
+ blocks.append(f"[{label}]\n{text}")
186
+ labels.append(label)
187
+ return RETRIEVAL_PREAMBLE + "\n\n" + "\n\n".join(blocks), labels
188
+
189
+ def _build_messages(
190
+ self, question: str, history: Sequence[dict[str, Any]] | None
191
+ ) -> tuple[list[dict[str, Any]], list[str]]:
192
+ messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
193
+ messages += self._truncate(history or [])
194
+
195
+ context, sources = self._retrieve(question)
196
+ # Retrieved passages ride along with the user's turn rather than being
197
+ # spliced into the system prompt. That keeps the cached prefix
198
+ # byte-stable across questions, which is worth more than the tidier
199
+ # placement.
200
+ content = f"{context}\n\n---\n\n{question}" if context else question
201
+ messages.append({"role": "user", "content": content})
202
+ return messages, sources
203
+
204
+ def _truncate(self, history: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
205
+ """Drop the oldest turns until the history fits the token budget.
206
+
207
+ The web client resends the whole conversation every request and trims
208
+ nothing, so the server has to.
209
+ """
210
+ kept: list[dict[str, Any]] = []
211
+ used = 0
212
+ for item in reversed(list(history)):
213
+ role, content = item.get("role"), (item.get("content") or "").strip()
214
+ if role not in ("user", "assistant") or not content:
215
+ continue
216
+ cost = self.engine.count_tokens(content) + 8
217
+ if used + cost > MAX_HISTORY_TOKENS:
218
+ break
219
+ kept.append({"role": role, "content": content})
220
+ used += cost
221
+ return list(reversed(kept))
222
+
223
+ def _wants_thinking(self, question: str) -> bool:
224
+ if self.thinking == "on":
225
+ return True
226
+ if self.thinking == "off":
227
+ return False
228
+ # "auto": reasoning earns its latency on conceptual questions, which is
229
+ # where this model is weakest and where no solver can help it.
230
+ return bool(_CONCEPTUAL_RE.search(question))
231
+
232
+ # ------------------------------------------------------------- tools
233
+
234
+ def _execute(self, call: ToolCall) -> dict[str, Any]:
235
+ for key, value in call.arguments.items():
236
+ reason = toolcall.degenerate_reason(value)
237
+ if reason:
238
+ return {
239
+ "status": "error",
240
+ "error_type": "DegenerateArgument",
241
+ "error": (
242
+ f"The value passed as '{key}' {reason}, which is not a real system. "
243
+ f"Re-read the question and pass the actual values, or say what is missing."
244
+ ),
245
+ }
246
+ return self.registry.execute(call.name, call.arguments)
247
+
248
+ # ----------------------------------------------------------- running
249
+
250
+ def stream(
251
+ self,
252
+ question: str,
253
+ history: Sequence[dict[str, Any]] | None = None,
254
+ max_tokens: int = 1536,
255
+ ) -> Generator[dict[str, Any], None, None]:
256
+ """Run one turn, yielding events as they happen.
257
+
258
+ Event types: `thinking`, `text`, `tool_start`, `tool_end`, `plot`,
259
+ `done`.
260
+ """
261
+ messages, sources = self._build_messages(question, history)
262
+ traces: list[ToolTrace] = []
263
+ plots: list[str] = []
264
+ answer_parts: list[str] = []
265
+ call_counts: dict[str, int] = {}
266
+ seen: set[str] = set()
267
+ # Accumulated across every generation in the turn: the engine's own
268
+ # stats only describe its most recent call, which for a tool-using
269
+ # question is the short synthesis pass and badly understates the work.
270
+ totals = {"prompt_tokens": 0, "cached_tokens": 0, "generated_tokens": 0, "prefill_seconds": 0.0, "decode_seconds": 0.0}
271
+
272
+ # Decided once, before the loop. Deciding it per-pass looked equivalent
273
+ # but was not: a conceptual question is answered on the *first* pass,
274
+ # which is never the final pass, so reasoning was silently never
275
+ # enabled for exactly the questions "auto" exists to help.
276
+ think_turn = self._wants_thinking(question)
277
+
278
+ for step in range(self.max_tool_steps + 1):
279
+ final_pass = step == self.max_tool_steps
280
+ tools = None if final_pass else self.tool_schemas
281
+ # Once a solver has produced the number, the number is the answer;
282
+ # reasoning over it only adds latency.
283
+ think = think_turn and not traces
284
+
285
+ if final_pass and traces:
286
+ messages.append({"role": "user", "content": SYNTHESIS_NUDGE})
287
+
288
+ prompt = self.engine.render(messages, tools=tools, enable_thinking=think)
289
+ gate = _StreamGate()
290
+ raw: list[str] = []
291
+
292
+ for chunk in self.engine.stream(
293
+ prompt,
294
+ sampling=self.engine.sampling.with_(max_tokens=max_tokens),
295
+ stop=("</tool_call>",) if tools else (),
296
+ think_budget=self.think_budget if think else None,
297
+ ):
298
+ if chunk.thinking:
299
+ yield {"type": "thinking", "text": chunk.text}
300
+ continue
301
+ raw.append(chunk.text)
302
+ visible = gate.feed(chunk.text)
303
+ if visible:
304
+ answer_parts.append(visible)
305
+ yield {"type": "text", "text": visible}
306
+
307
+ tail = gate.flush()
308
+ if tail:
309
+ answer_parts.append(tail)
310
+ yield {"type": "text", "text": tail}
311
+
312
+ stats = self.engine.last_stats
313
+ totals["prompt_tokens"] += stats.prompt_tokens
314
+ totals["cached_tokens"] += stats.cached_tokens
315
+ totals["generated_tokens"] += stats.generated_tokens
316
+ totals["prefill_seconds"] += stats.prefill_seconds
317
+ totals["decode_seconds"] += stats.decode_seconds
318
+
319
+ output = "".join(raw)
320
+ calls, _ = toolcall.parse(output)
321
+
322
+ if not calls:
323
+ break
324
+
325
+ # A tool call is arriving, so whatever prose preceded it was
326
+ # narration ("Let me compute that"), not the answer. Drop it from
327
+ # the answer text; the user already saw it stream past.
328
+ answer_parts.clear()
329
+ messages.append({"role": "assistant", "content": output})
330
+
331
+ for call in calls:
332
+ if call_counts.get(call.name, 0) >= MAX_CALLS_PER_TOOL:
333
+ continue
334
+ signature = f"{call.name}:{json.dumps(call.arguments, sort_keys=True, default=str)}"
335
+ if signature in seen:
336
+ continue
337
+ seen.add(signature)
338
+ call_counts[call.name] = call_counts.get(call.name, 0) + 1
339
+
340
+ yield {"type": "tool_start", "tool": call.name, "arguments": call.arguments}
341
+ result = self._execute(call)
342
+ traces.append(ToolTrace(call.name, call.arguments, result))
343
+ yield {
344
+ "type": "tool_end",
345
+ "tool": call.name,
346
+ "status": result.get("status", "success"),
347
+ "result": result,
348
+ }
349
+
350
+ plot_path = result.get("plot_path")
351
+ if plot_path and Path(plot_path).exists():
352
+ url = f"/plots/{Path(plot_path).name}"
353
+ plots.append(url)
354
+ yield {"type": "plot", "url": url}
355
+
356
+ messages.append(
357
+ {
358
+ "role": "tool",
359
+ "name": call.name,
360
+ "content": json.dumps(result, ensure_ascii=False, default=str),
361
+ }
362
+ )
363
+
364
+ answer = "".join(answer_parts).strip()
365
+ if not answer:
366
+ answer = self._recover(question, messages)
367
+ if answer:
368
+ yield {"type": "text", "text": answer}
369
+
370
+ yield {
371
+ "type": "done",
372
+ "answer": answer,
373
+ "traces": [{"tool": t.name, "arguments": t.arguments, "status": t.status} for t in traces],
374
+ "plots": plots,
375
+ "sources": sources,
376
+ "stats": {
377
+ "prompt_tokens": totals["prompt_tokens"],
378
+ "cached_tokens": totals["cached_tokens"],
379
+ "generated_tokens": totals["generated_tokens"],
380
+ "prefill_seconds": round(totals["prefill_seconds"], 3),
381
+ "decode_tps": round(
382
+ totals["generated_tokens"] / totals["decode_seconds"], 1
383
+ ) if totals["decode_seconds"] else 0.0,
384
+ },
385
+ }
386
+
387
+ def _recover(self, question: str, messages: list[dict[str, Any]]) -> str:
388
+ """Last resort when the loop produced no prose.
389
+
390
+ Re-asks with the tool results kept but the tool schemas withdrawn, so
391
+ the model has nothing to answer with except words.
392
+ """
393
+ messages = messages + [{"role": "user", "content": SYNTHESIS_NUDGE}]
394
+ prompt = self.engine.render(messages, tools=None, enable_thinking=False)
395
+ _, prose = toolcall.parse(self.engine.generate(prompt))
396
+ return prose.strip()
397
+
398
+ def run(
399
+ self,
400
+ question: str,
401
+ history: Sequence[dict[str, Any]] | None = None,
402
+ max_tokens: int = 1536,
403
+ ) -> AgentResult:
404
+ """Blocking variant of `stream`."""
405
+ result = AgentResult(answer="")
406
+ traces: list[ToolTrace] = []
407
+ for event in self.stream(question, history, max_tokens):
408
+ if event["type"] == "tool_end":
409
+ traces.append(ToolTrace(event["tool"], {}, event["result"]))
410
+ elif event["type"] == "done":
411
+ result = AgentResult(
412
+ answer=event["answer"],
413
+ traces=traces,
414
+ plots=event["plots"],
415
+ sources=event["sources"],
416
+ stats=event["stats"],
417
+ )
418
+ return result
controlai_agent/engine.py ADDED
@@ -0,0 +1,393 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local inference engine: MLX on Apple Silicon, with a prefix-reusing prompt cache.
2
+
3
+ Design notes that matter for anyone changing this file:
4
+
5
+ * **One persistent KV cache per engine, reused across every generation.**
6
+ The agent's prompt is dominated by a fixed prefix -- the system prompt plus
7
+ the JSON schemas of ~28 tools, which together are several thousand tokens.
8
+ Re-prefilling that on every tool step is what made the previous
9
+ implementation feel slow (measured: 6.4 s of prefill per step, five to six
10
+ steps per question). Here the cache is kept between calls and only the
11
+ tokens that actually differ from what the cache already holds are fed to
12
+ the model, so the fixed prefix is prefilled exactly once per process.
13
+
14
+ * **Generated tokens stay in the cache too.** A tool-calling turn is
15
+ append-only: prompt, then the assistant's tool call, then the tool result,
16
+ then more assistant text. Tracking generated tokens alongside prompt tokens
17
+ means continuing that turn costs only the tool-result tokens.
18
+
19
+ * **Streaming is real.** `stream()` yields text as the model produces it.
20
+ Nothing here buffers a whole response and re-emits it word by word.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any, Generator, Iterable, Sequence
30
+
31
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
32
+
33
+ DEFAULT_MODEL = os.environ.get("CONTROLAI_MODEL", "mlx-community/Qwen3-14B-4bit")
34
+ # The project's own LoRA adapters are deliberately NOT loaded by default. Both
35
+ # of them regressed the behaviour they were meant to improve: `behavior_v1`
36
+ # emits a spurious empty `<tool_call></tool_call>` as its first output on
37
+ # essentially every prompt (so no tool ever runs), and `sft_v2` generates empty
38
+ # output when no tools are exposed and string-typed numbers when they are.
39
+ # Set CONTROLAI_ADAPTER=<path> to load one anyway for A/B work.
40
+ DEFAULT_ADAPTER = os.environ.get("CONTROLAI_ADAPTER") or None
41
+
42
+
43
+ @dataclass
44
+ class SamplingConfig:
45
+ """Decoding parameters. Defaults follow Qwen3's own non-thinking recipe."""
46
+
47
+ temperature: float = 0.7
48
+ top_p: float = 0.8
49
+ top_k: int = 20
50
+ # Qwen3 recommends presence_penalty over the blunt repetition_penalty that
51
+ # the previous implementation applied at 1.15 across the board. A flat
52
+ # repetition penalty is actively harmful for this workload: it penalises
53
+ # the repeated structural tokens that matrices and JSON are made of
54
+ # (`[`, `0`, `,`) exactly when the model is emitting a tool call.
55
+ presence_penalty: float = 0.5
56
+ max_tokens: int = 1024
57
+
58
+ def with_(self, **kw: Any) -> "SamplingConfig":
59
+ merged = {**self.__dict__, **{k: v for k, v in kw.items() if v is not None}}
60
+ return SamplingConfig(**merged)
61
+
62
+
63
+ @dataclass
64
+ class Chunk:
65
+ """One streamed piece of model output."""
66
+
67
+ text: str
68
+ token: int
69
+ thinking: bool = False
70
+ tool_call: bool = False
71
+
72
+
73
+ @dataclass
74
+ class Stats:
75
+ prompt_tokens: int = 0
76
+ cached_tokens: int = 0
77
+ generated_tokens: int = 0
78
+ prefill_seconds: float = 0.0
79
+ decode_seconds: float = 0.0
80
+
81
+ @property
82
+ def decode_tps(self) -> float:
83
+ return self.generated_tokens / self.decode_seconds if self.decode_seconds else 0.0
84
+
85
+
86
+ class LocalEngine:
87
+ """Streaming text generation against a locally held MLX model."""
88
+
89
+ def __init__(
90
+ self,
91
+ model_id: str = DEFAULT_MODEL,
92
+ adapter_path: str | None = DEFAULT_ADAPTER,
93
+ sampling: SamplingConfig | None = None,
94
+ max_cache_tokens: int = 32768,
95
+ ) -> None:
96
+ from mlx_lm import load
97
+
98
+ self.model_id = model_id
99
+ self.adapter_path = adapter_path
100
+ self.sampling = sampling or SamplingConfig()
101
+ self.max_cache_tokens = max_cache_tokens
102
+
103
+ t0 = time.time()
104
+ if adapter_path:
105
+ self.model, self.tokenizer = load(model_id, adapter_path=adapter_path)
106
+ else:
107
+ self.model, self.tokenizer = load(model_id)
108
+ self.load_seconds = time.time() - t0
109
+
110
+ self._cache: list[Any] | None = None
111
+ self._cache_tokens: list[int] = []
112
+ self.last_stats = Stats()
113
+
114
+ # Resolved once: whether this checkpoint's chat template understands
115
+ # Qwen3-style `enable_thinking`, and the ids of the think delimiters.
116
+ self.supports_thinking = self._probe_thinking_support()
117
+ # `<think>`, `</think>`, `<tool_call>` and `</tool_call>` are each a
118
+ # single special token in the Qwen3 vocabulary. Watching for the token
119
+ # id rather than matching the rendered string is exact: it cannot be
120
+ # defeated by a tag split across two streamed chunks, and it costs one
121
+ # integer comparison per token instead of a substring scan.
122
+ self._think_open = self._single_token("<think>")
123
+ self._think_close = self._single_token("</think>")
124
+ self._tool_open = self._single_token("<tool_call>")
125
+ self._tool_close = self._single_token("</tool_call>")
126
+
127
+ # ------------------------------------------------------------------ setup
128
+
129
+ def _token_ids(self, text: str) -> list[int]:
130
+ try:
131
+ return self.tokenizer.encode(text, add_special_tokens=False)
132
+ except TypeError:
133
+ return self.tokenizer.encode(text)
134
+
135
+ def _single_token(self, text: str) -> int | None:
136
+ """The id of `text` if the tokenizer represents it as one token."""
137
+ ids = self._token_ids(text)
138
+ return ids[0] if len(ids) == 1 else None
139
+
140
+ def _probe_thinking_support(self) -> bool:
141
+ probe = [{"role": "user", "content": "hi"}]
142
+ try:
143
+ on = self.tokenizer.apply_chat_template(
144
+ probe, tokenize=False, add_generation_prompt=True, enable_thinking=True
145
+ )
146
+ off = self.tokenizer.apply_chat_template(
147
+ probe, tokenize=False, add_generation_prompt=True, enable_thinking=False
148
+ )
149
+ except Exception:
150
+ return False
151
+ return on != off
152
+
153
+ # -------------------------------------------------------------- rendering
154
+
155
+ def render(
156
+ self,
157
+ messages: Sequence[dict[str, Any]],
158
+ tools: Sequence[dict[str, Any]] | None = None,
159
+ enable_thinking: bool = False,
160
+ ) -> str:
161
+ kwargs: dict[str, Any] = {
162
+ "tokenize": False,
163
+ "add_generation_prompt": True,
164
+ }
165
+ if tools:
166
+ kwargs["tools"] = list(tools)
167
+ if self.supports_thinking:
168
+ kwargs["enable_thinking"] = enable_thinking
169
+ return self.tokenizer.apply_chat_template(list(messages), **kwargs)
170
+
171
+ def encode(self, text: str) -> list[int]:
172
+ return self.tokenizer.encode(text)
173
+
174
+ def count_tokens(self, text: str) -> int:
175
+ return len(self.tokenizer.encode(text))
176
+
177
+ # ------------------------------------------------------------------ cache
178
+
179
+ def _align_cache(self, tokens: list[int]) -> list[int]:
180
+ """Point the persistent cache at the longest prefix of `tokens` it
181
+ already holds, and return the tokens that still need prefilling."""
182
+ from mlx_lm.models.cache import (
183
+ can_trim_prompt_cache,
184
+ make_prompt_cache,
185
+ trim_prompt_cache,
186
+ )
187
+
188
+ reusable = 0
189
+ if self._cache is not None:
190
+ limit = min(len(self._cache_tokens), len(tokens))
191
+ while reusable < limit and self._cache_tokens[reusable] == tokens[reusable]:
192
+ reusable += 1
193
+
194
+ # A cache that cannot be trimmed back to the divergence point is worse
195
+ # than no cache: it would silently condition generation on stale
196
+ # tokens. Rebuild instead.
197
+ if self._cache is not None and reusable < len(self._cache_tokens):
198
+ if can_trim_prompt_cache(self._cache):
199
+ trim_prompt_cache(self._cache, len(self._cache_tokens) - reusable)
200
+ else:
201
+ self._cache, reusable = None, 0
202
+
203
+ if self._cache is None or reusable == 0:
204
+ self._cache = make_prompt_cache(self.model)
205
+ self._cache_tokens = []
206
+ reusable = 0
207
+
208
+ # MLX must be fed at least one token; an exact cache hit therefore
209
+ # rewinds by one and replays the final token.
210
+ if reusable == len(tokens) and reusable > 0:
211
+ from mlx_lm.models.cache import trim_prompt_cache as _trim
212
+
213
+ _trim(self._cache, 1)
214
+ reusable -= 1
215
+
216
+ self._cache_tokens = list(tokens[:reusable])
217
+ self.last_stats.cached_tokens = reusable
218
+ return list(tokens[reusable:])
219
+
220
+ def reset_cache(self) -> None:
221
+ self._cache = None
222
+ self._cache_tokens = []
223
+
224
+ def prewarm(self, text: str) -> int:
225
+ """Prefill a prompt prefix so the first real question doesn't pay for it.
226
+
227
+ Returns the number of tokens now resident in the cache. Called at
228
+ startup with the system-prompt-plus-tool-schemas prefix, which turns
229
+ first-question latency from a multi-second prefill into a cache hit.
230
+ """
231
+ import mlx.core as mx
232
+ from mlx_lm import stream_generate
233
+
234
+ tokens = self.encode(text)
235
+ to_feed = self._align_cache(tokens)
236
+ if to_feed:
237
+ self.model(mx.array(to_feed)[None], cache=self._cache)
238
+ mx.eval([c.state for c in self._cache])
239
+ self._cache_tokens = list(tokens)
240
+
241
+ # Prefilling alone leaves the single-token decode kernels uncompiled,
242
+ # so the first real question still paid several seconds of Metal
243
+ # warm-up. Generate and discard one token against a throwaway cache to
244
+ # force that compilation now, without disturbing the prefix cache.
245
+ from mlx_lm.models.cache import make_prompt_cache
246
+
247
+ scratch = make_prompt_cache(self.model)
248
+ for _ in stream_generate(
249
+ self.model, self.tokenizer, [tokens[-1]], max_tokens=1, prompt_cache=scratch
250
+ ):
251
+ break
252
+ return len(tokens)
253
+
254
+ # ------------------------------------------------------------- generation
255
+
256
+ def stream(
257
+ self,
258
+ prompt: str | list[int],
259
+ sampling: SamplingConfig | None = None,
260
+ stop: Iterable[str] = (),
261
+ think_budget: int | None = None,
262
+ ) -> Generator[Chunk, None, None]:
263
+ """Yield output chunks as they are generated.
264
+
265
+ `stop` sequences end generation as soon as they appear (the sequence
266
+ itself is emitted, since tool-call parsing wants the closing tag).
267
+ `think_budget` caps how many tokens may be spent inside a `<think>`
268
+ block: on overrun the block is closed by hand and the model is made to
269
+ answer, which bounds worst-case latency on a reasoning model.
270
+ """
271
+ from mlx_lm import stream_generate
272
+ from mlx_lm.sample_utils import make_logits_processors, make_sampler
273
+
274
+ cfg = sampling or self.sampling
275
+ tokens = self.encode(prompt) if isinstance(prompt, str) else list(prompt)
276
+
277
+ t0 = time.time()
278
+ to_feed = self._align_cache(tokens)
279
+ self.last_stats = Stats(
280
+ prompt_tokens=len(tokens),
281
+ cached_tokens=len(tokens) - len(to_feed),
282
+ )
283
+
284
+ sampler = make_sampler(temp=cfg.temperature, top_p=cfg.top_p, top_k=cfg.top_k)
285
+ logits_processors = (
286
+ make_logits_processors(presence_penalty=cfg.presence_penalty)
287
+ if cfg.presence_penalty
288
+ else None
289
+ )
290
+
291
+ stop = tuple(s for s in stop if s)
292
+ stop_ids = {self._tool_close} if "</tool_call>" in stop and self._tool_close else set()
293
+ text_stops = tuple(s for s in stop if not (s == "</tool_call>" and self._tool_close))
294
+ # Only the tail can contain a partial stop sequence, so matching over a
295
+ # bounded window keeps this O(1) per token instead of rescanning the
296
+ # whole response.
297
+ window = max((len(s) for s in text_stops), default=0) + 8
298
+
299
+ emitted: list[int] = []
300
+ tail = ""
301
+ budget_left = think_budget
302
+ in_think = False
303
+ in_tool_call = False
304
+ first_token_at: float | None = None
305
+ remaining = cfg.max_tokens
306
+
307
+ while remaining > 0:
308
+ forced_close = False
309
+ for resp in stream_generate(
310
+ self.model,
311
+ self.tokenizer,
312
+ to_feed,
313
+ max_tokens=remaining,
314
+ sampler=sampler,
315
+ logits_processors=logits_processors,
316
+ prompt_cache=self._cache,
317
+ ):
318
+ if first_token_at is None:
319
+ first_token_at = time.time()
320
+ self.last_stats.prefill_seconds = first_token_at - t0
321
+ emitted.append(resp.token)
322
+ self._cache_tokens.append(resp.token)
323
+ remaining -= 1
324
+ text = resp.text
325
+
326
+ # Exact, token-id state transitions. The tags themselves are
327
+ # never emitted -- the caller gets the content and the flags.
328
+ if resp.token == self._think_open:
329
+ in_think = True
330
+ continue
331
+ if resp.token == self._think_close:
332
+ in_think = False
333
+ continue
334
+ if resp.token == self._tool_open:
335
+ # Unlike the think tags, this one is emitted: the caller
336
+ # parses the `<tool_call>...</tool_call>` block out of the
337
+ # raw text. The flag lets it suppress the same text from
338
+ # the user-visible stream.
339
+ in_tool_call = True
340
+ if text and not in_think:
341
+ tail = (tail + text)[-window:] if window else ""
342
+ # Fallback for a checkpoint whose think tags are not single
343
+ # tokens; harmless when the ids above already matched.
344
+ if self._think_open is None and "<think>" in tail:
345
+ in_think = True
346
+ if self._think_close is None and "</think>" in tail:
347
+ in_think = False
348
+
349
+ yield Chunk(text=text, token=resp.token, thinking=in_think, tool_call=in_tool_call)
350
+
351
+ if in_think and budget_left is not None:
352
+ budget_left -= 1
353
+ if budget_left <= 0:
354
+ forced_close = True
355
+ break
356
+
357
+ if resp.token in stop_ids or (text_stops and any(s in tail for s in text_stops)):
358
+ remaining = 0
359
+ break
360
+ else:
361
+ remaining = 0
362
+
363
+ if not forced_close:
364
+ break
365
+
366
+ # Overran the thinking budget: close the block ourselves and let
367
+ # the same cache continue straight into the answer.
368
+ closer = "\n</think>\n\n"
369
+ closer_ids = self._token_ids(closer)
370
+ self._cache_tokens.extend(closer_ids)
371
+ to_feed = closer_ids
372
+ # Deliberately not yielded: this is a control action on the model,
373
+ # not model output. Emitting it put a bare "</think>" at the top of
374
+ # the answer whenever the budget was reached.
375
+ in_think = False
376
+ budget_left = None
377
+
378
+ now = time.time()
379
+ self.last_stats.generated_tokens = len(emitted)
380
+ self.last_stats.decode_seconds = now - (first_token_at or now)
381
+ if len(self._cache_tokens) > self.max_cache_tokens:
382
+ self.reset_cache()
383
+
384
+ def generate(
385
+ self,
386
+ prompt: str | list[int],
387
+ sampling: SamplingConfig | None = None,
388
+ stop: Iterable[str] = (),
389
+ think_budget: int | None = None,
390
+ ) -> str:
391
+ return "".join(
392
+ c.text for c in self.stream(prompt, sampling, stop, think_budget)
393
+ )
controlai_agent/orchestrator.py DELETED
@@ -1,1327 +0,0 @@
1
- """Control-LLM Agent Orchestrator: Multi-step tool calling, execution, grounding, and streaming synthesis loop."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import re
7
- from dataclasses import dataclass, field
8
- from pathlib import Path
9
- from typing import Any, Generator
10
-
11
- import os
12
- import sys
13
-
14
- try:
15
- from mlx_lm import generate as mlx_generate, load as mlx_load
16
- from mlx_lm.sample_utils import make_logits_processors
17
- HAS_MLX = True
18
- except ImportError:
19
- HAS_MLX = False
20
-
21
- try:
22
- import llama_cpp
23
- HAS_LLAMA_CPP = True
24
- except ImportError:
25
- HAS_LLAMA_CPP = False
26
-
27
- try:
28
- import ollama
29
- HAS_OLLAMA = True
30
- except ImportError:
31
- HAS_OLLAMA = False
32
-
33
- from transformers import AutoModelForCausalLM, AutoTokenizer
34
-
35
- from controlai_agent.prompts import CONTROLAI_SYSTEM_PROMPT
36
- from controlai_agent.registry import ToolRegistry, registry
37
- import controlai_agent.tools # noqa: F401 (ensure all tools are registered)
38
- from controlai_rag.index import get_shared_index
39
-
40
-
41
- @dataclass
42
- class ToolExecutionTrace:
43
- tool_name: str
44
- arguments: dict[str, Any]
45
- result: dict[str, Any]
46
-
47
-
48
- @dataclass
49
- class AgentResult:
50
- final_response: str
51
- tool_traces: list[ToolExecutionTrace] = field(default_factory=list)
52
- total_steps: int = 0
53
- raw_messages: list[dict[str, Any]] = field(default_factory=list)
54
- is_grounded: bool = True
55
- plots: list[str] = field(default_factory=list)
56
-
57
-
58
- def sanitize_json_escapes(raw: str) -> str:
59
- """Escape unescaped backslashes in JSON strings (e.g. \\dot, \\omega, \\mu in math/code)."""
60
- return re.sub(r"\\(?![/\"\\bfnrtu])", r"\\\\", raw)
61
-
62
-
63
- def fix_space_separated_arrays(json_str: str) -> str:
64
- """Convert MATLAB/Numpy space-separated lists inside brackets [1 2 3] to [1, 2, 3]."""
65
- def _fix_brackets(match: re.Match) -> str:
66
- content = match.group(1).strip()
67
- # Replace spaces between numbers with commas
68
- fixed = re.sub(r"([0-9eE\.\-+]+)\s+([0-9eE\.\-+]+)", r"\1, \2", content)
69
- fixed = re.sub(r"([0-9eE\.\-+]+)\s+([0-9eE\.\-+]+)", r"\1, \2", fixed)
70
- fixed = re.sub(r"([0-9eE\.\-+]+)\s+([0-9eE\.\-+]+)", r"\1, \2", fixed)
71
- return f"[{fixed}]"
72
- return re.sub(r"\[\s*([0-9eE\.\-+\s]+?)\s*\]", _fix_brackets, json_str)
73
-
74
-
75
- def close_unbalanced_json(raw: str) -> str:
76
- """Append the closing braces/brackets a truncated JSON object is missing.
77
-
78
- Small quantized models routinely drop the final `}` of a tool call (emitting
79
- `{"name": ..., "arguments": {...}` with the outer object left open). Without
80
- repair the call fails to parse, the tool never runs, and the turn collapses
81
- into an empty answer -- so balance the delimiters and let it parse.
82
- """
83
- stack: list[str] = []
84
- in_string = False
85
- escaped = False
86
- for ch in raw:
87
- if in_string:
88
- if escaped:
89
- escaped = False
90
- elif ch == "\\":
91
- escaped = True
92
- elif ch == '"':
93
- in_string = False
94
- continue
95
- if ch == '"':
96
- in_string = True
97
- elif ch in "{[":
98
- stack.append(ch)
99
- elif ch in "}]":
100
- if stack and ((ch == "}" and stack[-1] == "{") or (ch == "]" and stack[-1] == "[")):
101
- stack.pop()
102
-
103
- repaired = raw
104
- if in_string:
105
- repaired += '"'
106
- # A generation cut off by the token budget mid-array/object usually stops
107
- # right after a comma (about to write the next element) -- a dangling
108
- # trailing comma is invalid JSON even once the brackets below are
109
- # balanced ("[1, 0, 0,]" still fails to parse), so drop it first.
110
- if not in_string:
111
- repaired = re.sub(r",\s*$", "", repaired)
112
- for opener in reversed(stack):
113
- repaired += "}" if opener == "{" else "]"
114
- return repaired
115
-
116
-
117
- def parse_flexible_json(raw_str: str) -> dict[str, Any] | None:
118
- """Parse JSON with fallback to escape sanitization, array fixing, and non-strict control characters."""
119
- raw_str = raw_str.strip()
120
- try:
121
- obj = json.loads(raw_str, strict=False)
122
- if isinstance(obj, dict):
123
- return obj
124
- except Exception:
125
- pass
126
-
127
- try:
128
- sanitized = sanitize_json_escapes(raw_str)
129
- obj = json.loads(sanitized, strict=False)
130
- if isinstance(obj, dict):
131
- return obj
132
- except Exception:
133
- pass
134
-
135
- try:
136
- fixed_arrays = fix_space_separated_arrays(sanitize_json_escapes(raw_str))
137
- obj = json.loads(fixed_arrays, strict=False)
138
- if isinstance(obj, dict):
139
- return obj
140
- except Exception:
141
- pass
142
-
143
- # Last resort: the object is well-formed but truncated (a dropped closing
144
- # brace), so balance the delimiters and retry each variant.
145
- for candidate in (
146
- raw_str,
147
- sanitize_json_escapes(raw_str),
148
- fix_space_separated_arrays(sanitize_json_escapes(raw_str)),
149
- ):
150
- try:
151
- obj = json.loads(close_unbalanced_json(candidate), strict=False)
152
- if isinstance(obj, dict):
153
- return obj
154
- except Exception:
155
- continue
156
-
157
- return None
158
-
159
-
160
- # A doubled backslash is an accidental JSON-style escape when it precedes:
161
- # - a command name \\zeta \\frac \\sum
162
- # - a delimiter escape \\| \\{ \\} \\( \\) \\]
163
- # - \\[ that does NOT begin a spacing argument such as a genuine "\\[2pt]"
164
- # A real LaTeX row break inside matrix/aligned is followed by whitespace, a
165
- # newline, or a digit-led spacing option -- never by any of the above -- so
166
- # these rewrites leave legitimate line breaks untouched.
167
- _LATEX_DOUBLE_BACKSLASH_RE = re.compile(r"\\\\(?=[a-zA-Z|{}()\]])|\\\\(?=\[\s*[^\d\s])")
168
-
169
-
170
- def _fix_doubled_latex_backslashes(text: str) -> str:
171
- """Collapse an accidental JSON-escape-style double backslash before a LaTeX
172
- command or delimiter (\\\\zeta, \\\\|) down to the single backslash KaTeX
173
- expects (\\zeta, \\|).
174
-
175
- The base model was heavily trained on JSON-argument function calling, where
176
- a literal backslash must be written as `\\\\` inside a JSON string. That
177
- habit leaks into plain-text math even outside of a tool call. Left alone,
178
- `\\\\|x - x_f\\\\|^2` renders as a line break followed by a stray pipe,
179
- tearing a norm across two lines instead of drawing it.
180
- """
181
- return _LATEX_DOUBLE_BACKSLASH_RE.sub(lambda m: "\\", text)
182
-
183
-
184
- def _extract_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
185
- """Parse tool calls from <tool_call>, markdown code blocks, or raw JSON robustly."""
186
- calls: list[dict[str, Any]] = []
187
-
188
- # 1. Standard <tool_call> tags
189
- for match in re.finditer(r"<tool_call>\s*([\s\S]*?)\s*</tool_call>", text):
190
- obj = parse_flexible_json(match.group(1))
191
- if obj and "name" in obj:
192
- calls.append(obj)
193
-
194
- # 2. Markdown json blocks with tool call schema
195
- if not calls:
196
- for match in re.finditer(r"```(?:json)?\s*(\{\s*[\"']name[\"']\s*:[\s\S]*?\})\s*```", text):
197
- obj = parse_flexible_json(match.group(1))
198
- if obj and "name" in obj:
199
- calls.append(obj)
200
-
201
- # 3. Raw JSON object containing "name" and "arguments" / "parameters"
202
- if not calls and ('"name"' in text or "'name'" in text):
203
- match = re.search(r"(\{\s*[\"']name[\"']\s*:\s*[\"'][a-zA-Z0-9_]+[\"'][\s\S]*\})", text)
204
- if not match:
205
- # Generation was truncated mid-JSON (e.g. a repetition loop hit the
206
- # token budget before the array closed), so there's no closing "}"
207
- # to anchor on -- fall back to matching to the end of the string so
208
- # close_unbalanced_json can still repair it and _degenerate_array_reason
209
- # can refuse it, instead of the raw truncated JSON leaking into the chat.
210
- match = re.search(r"(\{\s*[\"']name[\"']\s*:\s*[\"'][a-zA-Z0-9_]+[\"'][\s\S]*)", text)
211
- if match:
212
- obj = parse_flexible_json(match.group(1))
213
- if obj and "name" in obj:
214
- calls.append(obj)
215
-
216
- # Clean pre-tool thought / raw JSON artifacts
217
- cleaned = re.sub(r"<tool_call>[\s\S]*?</tool_call>", "", text, flags=re.DOTALL)
218
- cleaned = re.sub(r"```(?:json)?\s*\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*```", "", cleaned)
219
- cleaned = re.sub(r"\{\s*[\"']name[\"']\s*:\s*[\"'][a-zA-Z0-9_]+[\"'][\s\S]*\}", "", cleaned)
220
- # Same truncated-JSON fallback as above, so the leftover raw JSON is
221
- # stripped from the visible text even when it never closed.
222
- cleaned = re.sub(r"\{\s*[\"']name[\"']\s*:\s*[\"'][a-zA-Z0-9_]+[\"'][\s\S]*", "", cleaned)
223
- # An orphaned <tool_call> tag with no matching close (the model started a
224
- # call, then abandoned it mid-generation for plain text) survives the
225
- # paired regex above -- strip any leftover tag so it never reaches the UI.
226
- cleaned = re.sub(r"</?tool_call>", "", cleaned)
227
- cleaned = cleaned.strip()
228
- cleaned = _fix_doubled_latex_backslashes(cleaned)
229
- return calls, cleaned
230
-
231
-
232
- # Applied across every inference backend below. Without it, a small quantized
233
- # model under low-temperature decoding has no defense against falling into a
234
- # token-repetition loop once it starts (observed in production as
235
- # routh_hurwitz_analysis coefficient arrays of 300+ repeated zeros): it burns
236
- # the entire max_tokens budget on garbage, which is both the direct cause of
237
- # the degenerate-array tool-call failures and a major source of latency.
238
- REPETITION_PENALTY = 1.15
239
-
240
- PROJECT_ROOT = Path(__file__).resolve().parent.parent
241
-
242
- # Our own fine-tuned model's repo -- every GGUF/Ollama/CUDA backend must load
243
- # ITS tokenizer and chat template (never a generic base model's), since the
244
- # tool-call format, special tokens, and vocab are specific to this fine-tune.
245
- CONTROLAI_HF_REPO = "atakankahya/ControlAI-Agent"
246
-
247
- # How many times a single tool may be invoked within one user turn. Two allows
248
- # a legitimate retry with corrected arguments after an error, while stopping
249
- # the model from spending its whole step budget re-running the same lookup.
250
- MAX_CALLS_PER_TOOL = 2
251
-
252
-
253
- # ---------------------------------------------------------------------------
254
- # Parameter provenance enforcement
255
- #
256
- # Prompt rules against inventing parameters demonstrably do not hold on a 4B
257
- # model: given "A times A" after an earlier LQR conversation, it fabricated a
258
- # brand-new B, resized Q, and ran a confident LQR synthesis -- three separate
259
- # prompt formulations failed to stop it. So faithfulness is enforced in code:
260
- # every 2D matrix handed to a tool must be traceable to the user's messages or
261
- # a prior tool result in this conversation, or the call is refused before it
262
- # executes and the model is told to ask the user for the missing value.
263
- # ---------------------------------------------------------------------------
264
-
265
- # Parameters exempt from the guard:
266
- # - C/D: conventional output-selector / feedthrough matrices (entries 0/1),
267
- # routinely and legitimately chosen by the designer, and harmless.
268
- # - desired_poles: pole locations are design choices derived from specs
269
- # ("settling time under 2s"), not data the user must dictate literally.
270
- _PROVENANCE_EXEMPT_PARAMS = {"C", "D", "desired_poles"}
271
-
272
- _NUMBER_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")
273
-
274
-
275
- def _extract_arrays_from_text(text: str) -> list[list]:
276
- """Find every parseable bracketed numeric array (1D or 2D) in free text."""
277
- arrays: list[list] = []
278
- n = len(text)
279
- i = 0
280
- while i < n:
281
- if text[i] != "[":
282
- i += 1
283
- continue
284
- depth = 0
285
- j = i
286
- while j < n:
287
- if text[j] == "[":
288
- depth += 1
289
- elif text[j] == "]":
290
- depth -= 1
291
- if depth == 0:
292
- break
293
- j += 1
294
- if depth != 0:
295
- i += 1
296
- continue
297
- candidate = text[i : j + 1]
298
- # Third variant: users mix separators freely ("[3 0, 1]"), which the
299
- # comma-only fixer can't handle -- insert a comma between any two
300
- # adjacent number tokens regardless of other separators present.
301
- mixed_fixed = re.sub(r"(?<=[\d.])\s+(?=[-\d.])", ", ", candidate)
302
- for variant in (candidate, fix_space_separated_arrays(candidate), mixed_fixed):
303
- try:
304
- parsed = json.loads(variant)
305
- except json.JSONDecodeError:
306
- continue
307
- if isinstance(parsed, list) and parsed:
308
- arrays.append(parsed)
309
- break
310
- # Only skip past this bracket char, not the whole span: inner arrays
311
- # of a 2D matrix should also be collected individually (rows are
312
- # legitimate 1D vectors in their own right).
313
- i += 1
314
- return arrays
315
-
316
-
317
- def _collect_numeric_leaves(obj: Any, arrays: list[list], numbers: set[float]) -> None:
318
- """Harvest nested numeric lists and scalars from a parsed JSON object."""
319
- if isinstance(obj, bool):
320
- return
321
- if isinstance(obj, (int, float)):
322
- numbers.add(float(obj))
323
- return
324
- if isinstance(obj, list):
325
- if obj and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in obj):
326
- arrays.append(list(obj))
327
- elif obj and all(isinstance(v, list) for v in obj):
328
- arrays.append(obj)
329
- for v in obj:
330
- _collect_numeric_leaves(v, arrays, numbers)
331
- return
332
- if isinstance(obj, dict):
333
- for v in obj.values():
334
- _collect_numeric_leaves(v, arrays, numbers)
335
-
336
-
337
- def _as_2d_float_array(value: Any):
338
- """Return value as a 2D float ndarray, or None if it isn't one."""
339
- import numpy as _np
340
-
341
- try:
342
- arr = _np.array(value, dtype=float)
343
- except (TypeError, ValueError):
344
- return None
345
- if arr.ndim == 1 and arr.size > 0:
346
- arr = arr.reshape(1, -1)
347
- if arr.ndim != 2 or arr.size == 0:
348
- return None
349
- return arr
350
-
351
-
352
- class ParameterProvenance:
353
- """Everything numeric the conversation has actually provided so far."""
354
-
355
- def __init__(self, messages: list[dict[str, Any]]) -> None:
356
- import numpy as _np
357
-
358
- self._np = _np
359
- self.arrays: list[Any] = []
360
- self.current_numbers: set[float] = set()
361
- all_user_texts: list[str] = []
362
- current_user_text = ""
363
-
364
- for msg in messages:
365
- role = msg.get("role")
366
- content = msg.get("content", "")
367
- if role == "user" and isinstance(content, str):
368
- all_user_texts.append(content)
369
- current_user_text = content
370
- elif role == "tool" and isinstance(content, str):
371
- try:
372
- parsed = json.loads(content)
373
- except json.JSONDecodeError:
374
- continue
375
- found_arrays: list[list] = []
376
- found_numbers: set[float] = set()
377
- _collect_numeric_leaves(parsed, found_arrays, found_numbers)
378
- for a in found_arrays:
379
- arr = _as_2d_float_array(a)
380
- if arr is not None:
381
- self.arrays.append(arr)
382
- # Scalars computed by tools (gains, margins) are legitimate
383
- # inputs for later steps.
384
- self.current_numbers |= found_numbers
385
-
386
- for text in all_user_texts:
387
- for a in _extract_arrays_from_text(text):
388
- arr = _as_2d_float_array(a)
389
- if arr is not None:
390
- self.arrays.append(arr)
391
- # Loose scalars only count from the CURRENT user message: numbers from
392
- # an earlier, different problem are exactly what must not silently
393
- # seed a new Q or R.
394
- for m in _NUMBER_RE.finditer(current_user_text):
395
- try:
396
- self.current_numbers.add(float(m.group()))
397
- except ValueError:
398
- pass
399
-
400
- def _matches_known_array(self, arr) -> bool:
401
- np_ = self._np
402
- for known in self.arrays:
403
- for cand in (known, known.T):
404
- if cand.shape == arr.shape and np_.allclose(cand, arr, rtol=1e-6, atol=1e-9):
405
- return True
406
- return False
407
-
408
- def _number_provided(self, x: float) -> bool:
409
- if x in (0.0, 1.0, -1.0):
410
- return True
411
- return any(abs(x - n) <= 1e-9 * max(1.0, abs(n)) for n in self.current_numbers)
412
-
413
- def verify(self, value: Any) -> bool:
414
- """True if this matrix is traceable to the conversation."""
415
- np_ = self._np
416
- arr = _as_2d_float_array(value)
417
- if arr is None:
418
- return True # not a matrix -- out of scope for this guard
419
-
420
- if self._matches_known_array(arr):
421
- return True
422
-
423
- # 1x1 "matrix" wrapping a scalar the user stated (R=1 -> [[1]]).
424
- if arr.shape == (1, 1):
425
- return self._number_provided(float(arr[0, 0]))
426
-
427
- # Identity / zero matrices are structural, not data.
428
- if arr.shape[0] == arr.shape[1]:
429
- if np_.allclose(arr, np_.eye(arr.shape[0])) or np_.allclose(arr, 0.0):
430
- return True
431
- # diag(...) built from numbers in the current message, e.g. the
432
- # user wrote "Q=diag([10, 1])" in prose rather than as an array.
433
- if np_.allclose(arr, np_.diag(np_.diag(arr))):
434
- return all(self._number_provided(float(d)) for d in np_.diag(arr))
435
-
436
- return False
437
-
438
-
439
- # Real control-engineering coefficient/numerator/denominator arrays are
440
- # essentially always short -- a 10th-order polynomial (already an extreme
441
- # hand-solved case) has 11 coefficients. A flat numeric array cannot be
442
- # provenance-traced the way a matrix can (a legitimately *expanded* factored
443
- # polynomial, e.g. (s+1)(s+2)(s+3) -> [1,6,11,6], never appears verbatim in
444
- # the user's text, so a strict "must match the conversation" rule would
445
- # wrongly block correct derivations). Instead this catches the actual
446
- # observed failure mode directly: a runaway repetition loop, where a small
447
- # model gets stuck emitting the same value and the token budget cuts it off
448
- # mid-array -- e.g. 300+ elements of mostly zeros for routh_hurwitz_analysis
449
- # on a question that never gave it a polynomial at all.
450
- _MAX_SANE_1D_ARRAY_LEN = 25
451
- _MAX_IDENTICAL_RUN = 6
452
-
453
-
454
- def _degenerate_array_reason(values: list) -> str | None:
455
- if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values):
456
- return None
457
- if len(values) > _MAX_SANE_1D_ARRAY_LEN:
458
- return f"has {len(values)} elements, far beyond any real control-engineering array of this kind"
459
- run = 1
460
- for i in range(1, len(values)):
461
- run = run + 1 if values[i] == values[i - 1] else 1
462
- if run >= _MAX_IDENTICAL_RUN:
463
- return f"repeats the value {values[i]!r} {run}+ times in a row -- a runaway generation loop, not real data"
464
- return None
465
-
466
-
467
- def _check_parameter_provenance(
468
- tool_args: dict[str, Any], messages: list[dict[str, Any]]
469
- ) -> tuple[str, Any, str] | None:
470
- """Return (param_name, value, reason) for the first bad matrix/array, else None."""
471
- provenance = ParameterProvenance(messages)
472
- for param, value in tool_args.items():
473
- if param in _PROVENANCE_EXEMPT_PARAMS:
474
- continue
475
- if not (isinstance(value, list) and value):
476
- continue
477
- if isinstance(value[0], list):
478
- if not provenance.verify(value):
479
- return param, value, "was not provided by the user in this conversation and did not come from any prior tool result"
480
- else:
481
- reason = _degenerate_array_reason(value)
482
- if reason:
483
- return param, value, reason
484
- return None
485
-
486
-
487
- # ---------------------------------------------------------------------------
488
- # RAG fast path
489
- #
490
- # The system prompt already tells the model "for definitional questions,
491
- # answer from the retrieved reference passages, do not run a numeric solver"
492
- # -- but that instruction demonstrably does not hold on a 4B model either: in
493
- # production, "what is Routh-Hurwitz, explain with an example" (fully
494
- # answerable from the Nise/Ogata passages already injected into the system
495
- # prompt by _get_grounded_instruction) still drove the model through 4 tool
496
- # calls (a redundant re-search plus three unrelated numeric tools) and 5
497
- # sequential full-length generations before it produced an answer. Since
498
- # prompting alone can't be trusted here any more than it can for parameter
499
- # provenance above, this is enforced the same way: in code.
500
- #
501
- # A real computational request in this domain essentially always names a
502
- # concrete number (a coefficient, a gain, a frequency) or an explicit
503
- # computational verb ("plot", "design", "simulate"); a pure "what is X" /
504
- # "explain X" / "how does X work" question has neither. This heuristic only
505
- # ever widens back to the full tool loop on a false negative (a computational
506
- # question with no digits and none of these verbs) -- it never narrows
507
- # correctness, since the loop it skips still runs whenever this returns True.
508
- _COMPUTATION_KEYWORDS = (
509
- "plot", "simulate", "simulation", "compute", "calculate", "design",
510
- "solve", "gain", "matrix", "matrices", "pole", "place", "locus", "bode",
511
- "nyquist", "margin", "response", "transfer function", "eigen",
512
- "controllab", "observab", "lyapunov", "kalman", "mpc", "invert",
513
- "determinant", "transpose", "multiply", "rank", "code", "script",
514
- )
515
-
516
-
517
- def _needs_tools(user_prompt: str) -> bool:
518
- """True if the question plausibly needs a numeric tool rather than being
519
- answerable straight from grounded reference text."""
520
- if any(ch.isdigit() for ch in user_prompt):
521
- return True
522
- lower = user_prompt.lower()
523
- return any(kw in lower for kw in _COMPUTATION_KEYWORDS)
524
-
525
-
526
- def _fabrication_refusal_note(refusals: list[str]) -> str:
527
- """Extra synthesis instruction appended when a tool call was refused for
528
- inventing a parameter.
529
-
530
- Without this, the model's forced final-answer turn happily hallucinates a
531
- plausible-looking numeric replacement anyway: observed directly with
532
- sft_v2 on "design an LQR controller for A = [[0, 1], [-2, -3]]" (no B, Q,
533
- R given) -- the tool call was correctly REFUSED by the provenance guard,
534
- but the final prose still confidently printed a fully invented gain
535
- $K = [1.83, 1.83]$, silently routing around its own refusal. The system
536
- prompt already forbids this (rule 6), but that alone doesn't hold on a 4B
537
- model any more than the provenance rules did in prose form -- so the
538
- refusal is restated directly in the synthesis turn itself, where it can't
539
- be missed.
540
- """
541
- if not refusals:
542
- return ""
543
- return (
544
- "\n\nIMPORTANT: at least one tool call above was REFUSED for inventing a parameter you were never "
545
- "given (see the REFUSED error message(s) in the tool results above for exactly which one and why). "
546
- "This means you do NOT have enough information to compute a numeric answer for that part of the "
547
- "request. Do NOT invent a substitute number, gain, matrix, or result to answer anyway -- state "
548
- "plainly what is missing and ask the user for it instead of guessing."
549
- )
550
-
551
-
552
- # The web frontend resends the entire growing conversation on every turn
553
- # (web/app.js) with no client-side trimming, and nothing here capped it
554
- # either: a long chat would eventually exceed the GGUF backend's
555
- # n_ctx=16384 and error outright, or just keep getting slower with no
556
- # ceiling at all on the PyTorch/MLX backends. Budget is deliberately well
557
- # under 16384 to leave headroom for the system prompt, injected RAG
558
- # passages, the full tool-schema catalog, and the current turn's own
559
- # multi-step tool loop -- all of which share the same context window.
560
- MAX_HISTORY_TOKENS = 6000
561
-
562
-
563
- def _truncate_history(
564
- history: list[dict[str, Any]] | None, tokenizer: Any, max_tokens: int = MAX_HISTORY_TOKENS
565
- ) -> list[dict[str, Any]] | None:
566
- """Keep the most recent turns of `history` that fit within max_tokens,
567
- dropping the oldest first so a long conversation degrades (forgets early
568
- turns) instead of failing outright."""
569
- if not history:
570
- return history
571
-
572
- kept: list[dict[str, Any]] = []
573
- total = 0
574
- for item in reversed(history):
575
- content = item.get("content", "")
576
- if not isinstance(content, str) or not content:
577
- continue
578
- try:
579
- n = len(tokenizer.encode(content))
580
- except Exception:
581
- n = len(content) // 4 # rough fallback if the tokenizer call itself fails
582
- if kept and total + n > max_tokens:
583
- break
584
- kept.append(item)
585
- total += n
586
-
587
- kept.reverse()
588
- # A leading assistant turn with no preceding user turn would read as a
589
- # reply to nothing -- drop it.
590
- while kept and kept[0].get("role") != "user":
591
- kept.pop(0)
592
- return kept
593
-
594
-
595
- class ControlAIAgent:
596
- """Universal Control Engineering Agent supporting GGUF, Ollama C++, Apple MLX, and PyTorch."""
597
-
598
- def __init__(
599
- self,
600
- model_path: str = "mlx-community/Qwen3-4B-Instruct-2507-4bit",
601
- adapter_path: str | None = None,
602
- tool_registry: ToolRegistry = registry,
603
- max_tool_steps: int = 4,
604
- ) -> None:
605
- self.model_path = model_path
606
-
607
- # Auto-detect trained LoRA adapter if none explicitly provided
608
- default_adapter = PROJECT_ROOT / "adapters" / "controlai_qwen3_4b_sft_v2"
609
- if adapter_path is None and default_adapter.exists():
610
- adapter_path = str(default_adapter)
611
-
612
- self.adapter_path = adapter_path
613
- self.registry = tool_registry
614
- self.max_tool_steps = max_tool_steps
615
-
616
- # Detect platform & backend
617
- self.is_ollama = str(model_path).startswith("ollama")
618
- self.is_gguf = str(model_path).endswith(".gguf") or "gguf" in str(model_path).lower()
619
- self.is_mlx = HAS_MLX and not self.is_ollama and not self.is_gguf and not str(model_path).startswith("Qwen/") and not os.environ.get("FORCE_TRANSFORMERS")
620
-
621
- if self.is_ollama:
622
- self.ollama_model = model_path.split(":", 1)[1] if ":" in str(model_path) else "controlai"
623
- self.hf_tokenizer = AutoTokenizer.from_pretrained(CONTROLAI_HF_REPO, trust_remote_code=True)
624
- elif self.is_gguf:
625
- if not HAS_LLAMA_CPP:
626
- raise ImportError("llama-cpp-python is required to run GGUF models. Install it with: pip install llama-cpp-python")
627
- self.llama_model = llama_cpp.Llama(
628
- model_path=str(model_path),
629
- n_gpu_layers=-1, # Offload all layers to Metal / CUDA GPU
630
- n_ctx=16384,
631
- verbose=False,
632
- )
633
- # A local .gguf path carries no tokenizer/chat-template of its own --
634
- # always load ours (never a generic base model's), same as the
635
- # auto-downloaded deployment path below.
636
- local_fused = PROJECT_ROOT / "models" / "controlai_fused"
637
- tokenizer_source = str(local_fused) if local_fused.exists() else CONTROLAI_HF_REPO
638
- self.hf_tokenizer = AutoTokenizer.from_pretrained(tokenizer_source, trust_remote_code=True)
639
- elif self.is_mlx:
640
- if adapter_path:
641
- self.model, self.mlx_tokenizer = mlx_load(model_path, adapter_path=adapter_path)
642
- else:
643
- self.model, self.mlx_tokenizer = mlx_load(model_path)
644
- self.hf_tokenizer = AutoTokenizer.from_pretrained(model_path)
645
- else:
646
- # Universal Linux / Cloud / HuggingFace Spaces backend: fast C++ GGUF
647
- # of OUR OWN fine-tuned ControlAI model (never a generic base model).
648
- #
649
- # Q8_0, not Q4_K_M: measured directly against this exact model on the
650
- # "simulate a step response" prompt (3 runs each). Q4_K_M mis-routed
651
- # tool calls in 3/3 runs (reached for continuous_lqr/bode_analysis
652
- # instead of simulate_step_response, then hallucinated inconsistent
653
- # overshoot values -- 8.6%, 43.1%, 25.4%, none near the true ~9.5%).
654
- # Q6_K, Q8_0, and full f16 all called the correct tool with
655
- # consistent, tool-verified numbers in 3/3 runs apiece, matching
656
- # local MLX behavior -- Q8_0 was picked among those three because it
657
- # was both the fastest of the three in this test (8-19s vs Q6_K's
658
- # 11-25s and f16's 11-22s) and, at 8.5 bits/weight, close enough to
659
- # f16 to be considered near-lossless, at roughly half f16's size.
660
- gguf_repo = os.environ.get("CONTROLAI_GGUF_REPO", CONTROLAI_HF_REPO)
661
- gguf_filename = os.environ.get("CONTROLAI_GGUF_FILENAME", "*controlai-q8_0.gguf")
662
- # Escape hatch for measuring the PyTorch/CUDA path against the GGUF
663
- # path on real deployment hardware -- set to "pytorch" to skip the
664
- # GGUF attempt entirely and go straight to the branch below.
665
- force_backend = os.environ.get("CONTROLAI_BACKEND", "").lower()
666
-
667
- self.llama_model = None
668
- if HAS_LLAMA_CPP and force_backend != "pytorch":
669
- try:
670
- threads = min(os.cpu_count() or 2, 4)
671
- print(f"Loading GGUF ControlAI model ({gguf_filename}) from {gguf_repo} (threads: {threads})...")
672
- self.llama_model = llama_cpp.Llama.from_pretrained(
673
- repo_id=gguf_repo,
674
- filename=gguf_filename,
675
- n_ctx=16384,
676
- n_threads=threads,
677
- verbose=False,
678
- )
679
- self.is_gguf = True
680
- self.hf_tokenizer = AutoTokenizer.from_pretrained(CONTROLAI_HF_REPO, trust_remote_code=True)
681
- print("GGUF ControlAI model loaded successfully via llama_cpp.")
682
- except Exception as exc:
683
- print(f"Notice: llama_cpp GGUF auto-load failed, falling back to PyTorch: {exc}")
684
- self.is_gguf = False
685
-
686
- if not self.is_gguf:
687
- # PyTorch fallback on CUDA or if the GGUF engine is unavailable.
688
- # Always our own fine-tuned model, never a generic base model.
689
- import torch
690
- num_threads = min(os.cpu_count() or 2, 4)
691
- try:
692
- torch.set_num_threads(num_threads)
693
- except Exception:
694
- pass
695
-
696
- hf_id = CONTROLAI_HF_REPO if "mlx" in str(model_path) or str(model_path).startswith("Qwen/") else model_path
697
- print(f"Loading PyTorch model: {hf_id} (threads: {num_threads})...")
698
- self.hf_tokenizer = AutoTokenizer.from_pretrained(hf_id, trust_remote_code=True)
699
- has_cuda = torch.cuda.is_available()
700
- # float16, not bfloat16: live-tested bf16 against this exact
701
- # model on the zeta/wn step-response prompt and it reproducibly
702
- # derived the wrong closed-loop coefficients (2.56/2.1952-ish
703
- # instead of the correct 1.68/1.96) across both greedy and
704
- # temperature=0.2 sampling -- the one thing GGUF/MLX (which got
705
- # this right from the same weights) don't share with bf16 is
706
- # its coarser 7-bit mantissa. Testing fp16's extra precision as
707
- # the fix for that specific divergence.
708
- dtype = torch.float16 if has_cuda else torch.float32
709
- # SDPA is a large, free speedup over the "eager" attention
710
- # default -- meaningful for the long tool-schema prompt prefix.
711
- attn_impl = "sdpa" if has_cuda else None
712
- self.model = AutoModelForCausalLM.from_pretrained(
713
- hf_id,
714
- torch_dtype=dtype,
715
- low_cpu_mem_usage=True,
716
- trust_remote_code=True,
717
- attn_implementation=attn_impl,
718
- )
719
- if has_cuda:
720
- # device_map="auto" infers available VRAM at load time; on
721
- # ZeroGPU no physical GPU is attached to the process yet at
722
- # this point (that only happens inside a @spaces.GPU call),
723
- # so it can silently offload layers to CPU. An explicit
724
- # move avoids that and any ambiguity about what actually
725
- # ran where.
726
- self.model = self.model.to("cuda")
727
- # The fused ControlAI model already has the LoRA weights merged in;
728
- # only apply a separate adapter when loading a plain base model.
729
- if hf_id != CONTROLAI_HF_REPO and adapter_path and Path(adapter_path).exists():
730
- try:
731
- from peft import PeftModel
732
- self.model = PeftModel.from_pretrained(self.model, adapter_path)
733
- print(f"Loaded PEFT LoRA adapter from: {adapter_path}")
734
- except Exception as exc:
735
- print(f"Warning: Could not load LoRA adapter in PyTorch: {exc}")
736
- print(f"PyTorch model loaded successfully. Model device: {next(self.model.parameters()).device}")
737
-
738
- # Initialize local offline RAG index
739
- try:
740
- self.rag_index = get_shared_index()
741
- except Exception:
742
- self.rag_index = None
743
-
744
- def _generate(self, prompt: str, max_tokens: int = 2000) -> str:
745
- """Universal text generation handling Ollama C++, GGUF llama_cpp, MLX, and PyTorch."""
746
- if self.is_ollama:
747
- res = ollama.generate(
748
- model=self.ollama_model,
749
- prompt=prompt,
750
- options={"temperature": 0.2, "num_predict": max_tokens, "repeat_penalty": REPETITION_PENALTY},
751
- )
752
- return res.get("response", "").strip()
753
- elif self.is_gguf:
754
- # llama_cpp defaults repeat_penalty to 1.0 (fully off) when unset --
755
- # it does NOT inherit any sane default, so this must be passed explicitly.
756
- output = self.llama_model(
757
- prompt,
758
- max_tokens=max_tokens,
759
- stop=["<|im_end|>", "<|endoftext|>"],
760
- temperature=0.2,
761
- repeat_penalty=REPETITION_PENALTY,
762
- )
763
- return output["choices"][0]["text"].strip()
764
- elif self.is_mlx:
765
- return mlx_generate(
766
- self.model,
767
- self.mlx_tokenizer,
768
- prompt=prompt,
769
- max_tokens=max_tokens,
770
- logits_processors=make_logits_processors(repetition_penalty=REPETITION_PENALTY),
771
- verbose=False,
772
- ).strip()
773
- else:
774
- import time as _time
775
- import torch
776
- inputs = self.hf_tokenizer(prompt, return_tensors="pt").to(self.model.device)
777
- prompt_tokens = inputs["input_ids"].shape[1]
778
- eos_ids = [
779
- tid
780
- for tid in (self.hf_tokenizer.eos_token_id, self.hf_tokenizer.convert_tokens_to_ids("<|im_end|>"))
781
- if tid is not None and tid >= 0
782
- ] or None
783
- t0 = _time.time()
784
- print(f"[_generate] starting: prompt_tokens={prompt_tokens} max_new_tokens={max_tokens} eos_ids={eos_ids}")
785
- with torch.no_grad():
786
- outputs = self.model.generate(
787
- **inputs,
788
- max_new_tokens=max_tokens,
789
- # Pure greedy (do_sample=False) reproducibly derived the
790
- # WRONG closed-loop coefficients for a plain zeta/wn step
791
- # response on this exact model (2.56/2.1952 instead of the
792
- # correct 1.68/1.96, in 3/3 identical runs on deployed
793
- # bf16) -- low-temperature sampling, matching the GGUF/MLX
794
- # backends, is the fix being tested for that failure.
795
- do_sample=True,
796
- temperature=0.2,
797
- top_p=None,
798
- top_k=None,
799
- repetition_penalty=REPETITION_PENALTY,
800
- eos_token_id=eos_ids,
801
- pad_token_id=self.hf_tokenizer.pad_token_id or self.hf_tokenizer.eos_token_id,
802
- )
803
- new_tokens = outputs[0][prompt_tokens:]
804
- elapsed = _time.time() - t0
805
- print(f"[_generate] done: generated_tokens={len(new_tokens)} elapsed={elapsed:.1f}s ({len(new_tokens)/max(elapsed,0.001):.1f} tok/s)")
806
- return self.hf_tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
807
-
808
- def _get_grounded_instruction(self, user_prompt: str, base_instruction: str) -> str:
809
- """Retrieve relevant textbook theorems and inject grounding into system instructions."""
810
- if not self.rag_index or not self.rag_index.chunks:
811
- return base_instruction
812
-
813
- try:
814
- rag_hits = self.rag_index.search(user_prompt, top_k=6)
815
- high_rel = [h for h in rag_hits if h.get("score", 0) > 2.5]
816
- if not high_rel:
817
- return base_instruction
818
-
819
- ref_texts = []
820
- for h in high_rel[:4]:
821
- # Cite the cleaned display name, never the raw indexed filename.
822
- label = h.get("source_name") or h.get("filename", "Reference")
823
- page = h.get("page")
824
- page_str = f", p. {page}" if page else ""
825
- clean_chunk = " ".join(h.get("text", "").split())[:900].strip()
826
- ref_texts.append(f"[{label}{page_str}]\n{clean_chunk}")
827
-
828
- return (
829
- base_instruction
830
- + "\n\n### Grounded Reference Context from Canonical Control Literature:\n"
831
- + "\n\n".join(ref_texts)
832
- + "\n\nThese passages were retrieved from the local library for THIS question. When the "
833
- "question asks what a specific author or textbook says, answer directly from these "
834
- "passages -- that is already the reference lookup, so do not call a numeric solver tool "
835
- "to answer a conceptual question. Where a passage conflicts with your own recollection, "
836
- "TRUST THE PASSAGE. Cite using exactly the bracketed label shown above (for example "
837
- "[Nise, Control Systems Engineering, p. 583]); never invent a citation and never print a "
838
- "raw filename, file extension, or course code."
839
- )
840
- except Exception as exc:
841
- print(f"[_get_grounded_instruction] FAILED, continuing ungrounded: {type(exc).__name__}: {exc}")
842
- return base_instruction
843
-
844
- def _direct_answer(
845
- self,
846
- user_prompt: str,
847
- system_instruction: str,
848
- history: list[dict[str, Any]] | None = None,
849
- max_tokens: int = 1400,
850
- ) -> str:
851
- """Answer the question with no tools exposed at all.
852
-
853
- This is the recovery path for when the tool loop fails to produce usable
854
- prose -- the model spent its steps on tool calls, or its synthesis turn
855
- came back empty or as yet another tool call. Re-asking with tools=None
856
- and without the tool-result transcript removes whatever was derailing it
857
- (and shortens the prompt considerably), which reliably yields a real
858
- answer instead of a canned "analysis complete" placeholder. The grounded
859
- system instruction is kept, so retrieved reference passages are still
860
- available to answer from.
861
- """
862
- messages: list[dict[str, Any]] = []
863
- if system_instruction:
864
- messages.append({"role": "system", "content": system_instruction})
865
- if history:
866
- for item in history:
867
- r = item.get("role")
868
- c = item.get("content")
869
- if r in ("user", "assistant") and c:
870
- messages.append({"role": r, "content": c})
871
- messages.append({"role": "user", "content": user_prompt})
872
-
873
- try:
874
- rendered = self.hf_tokenizer.apply_chat_template(
875
- messages, tools=None, tokenize=False, add_generation_prompt=True
876
- )
877
- _, cleaned = _extract_tool_calls(self._generate(rendered, max_tokens=max_tokens))
878
- return cleaned
879
- except Exception as exc:
880
- # This is the last line of defense before the user sees the canned
881
- # placeholder -- if it fails, that failure must leave a trace
882
- # instead of vanishing, or a real bug here is undiagnosable.
883
- print(f"[_direct_answer] FAILED for prompt {user_prompt[:80]!r}: {type(exc).__name__}: {exc}")
884
- return ""
885
-
886
- def _execute_with_provenance(
887
- self,
888
- tool_name: str,
889
- tool_args: dict[str, Any],
890
- messages: list[dict[str, Any]],
891
- ) -> dict[str, Any]:
892
- """Run a tool only after every matrix argument is traced to the
893
- conversation; refuse fabricated inputs before they execute."""
894
- try:
895
- fabricated = _check_parameter_provenance(tool_args, messages)
896
- except Exception:
897
- fabricated = None # the guard must never take down a legitimate call
898
- if fabricated is not None:
899
- param, value, reason = fabricated
900
- # Never echo a runaway array (possibly hundreds of elements) back
901
- # into the context -- it wastes the token budget and risks
902
- # priming the exact same repetition pathology again.
903
- shown = value if len(json.dumps(value)) < 200 else f"[{len(value)}-element array, truncated]"
904
- return {
905
- "status": "error",
906
- "error_type": "FabricatedParameter",
907
- "error": (
908
- f"REFUSED: the value passed as '{param}' ({shown}) {reason}. Inventing or "
909
- f"malformed parameter values is forbidden. Do NOT retry this tool with another "
910
- f"guessed or partially-repeated '{param}' -- if you don't actually have a concrete "
911
- f"value for it, this tool cannot be used for this question. Answer from your own "
912
- f"knowledge or the retrieved reference passages instead, or tell the user what's "
913
- f"missing."
914
- ),
915
- }
916
- return self.registry.execute(tool_name, tool_args)
917
-
918
- def run(
919
- self,
920
- user_prompt: str,
921
- system_instruction: str = CONTROLAI_SYSTEM_PROMPT,
922
- history: list[dict[str, Any]] | None = None,
923
- max_tokens_per_step: int = 2500,
924
- verbose: bool = False,
925
- ) -> AgentResult:
926
- """Execute a complete agent interaction loop synchronously."""
927
- history = _truncate_history(history, self.hf_tokenizer)
928
- messages: list[dict[str, Any]] = []
929
- effective_sys = self._get_grounded_instruction(user_prompt, system_instruction)
930
-
931
- # RAG fast path: a conceptual/definitional question that already has
932
- # strong grounded passages needs one generation, not a multi-step tool
933
- # loop. See the _needs_tools docstring for why this is safe.
934
- if effective_sys != system_instruction and not _needs_tools(user_prompt):
935
- fast_answer = self._direct_answer(user_prompt, effective_sys, history)
936
- if fast_answer:
937
- return AgentResult(
938
- final_response=fast_answer,
939
- tool_traces=[],
940
- total_steps=1,
941
- raw_messages=[{"role": "system", "content": effective_sys}, {"role": "user", "content": user_prompt}],
942
- is_grounded=True,
943
- plots=[],
944
- )
945
-
946
- if effective_sys:
947
- messages.append({"role": "system", "content": effective_sys})
948
-
949
- if history:
950
- for item in history:
951
- r = item.get("role")
952
- c = item.get("content")
953
- if r in ("user", "assistant") and c:
954
- messages.append({"role": r, "content": c})
955
-
956
- messages.append({"role": "user", "content": user_prompt})
957
-
958
- tools_schema = self.registry.get_tool_schemas()
959
- traces: list[ToolExecutionTrace] = []
960
- plots: list[str] = []
961
- called_signatures: set[str] = set()
962
- tool_call_counts: dict[str, int] = {}
963
- fabrication_refusals: list[str] = []
964
-
965
- for step in range(1, self.max_tool_steps + 1):
966
- rendered_prompt = self.hf_tokenizer.apply_chat_template(
967
- messages,
968
- tools=tools_schema,
969
- tokenize=False,
970
- add_generation_prompt=True,
971
- )
972
-
973
- model_output = self._generate(rendered_prompt, max_tokens=max_tokens_per_step)
974
-
975
- tool_calls, pre_text = _extract_tool_calls(model_output)
976
-
977
- if not tool_calls and not fabrication_refusals:
978
- # The model chose to stop calling tools but produced no usable
979
- # text either (typically right after a tool error it has no
980
- # good way to recover from in-context). Retry once with no
981
- # tools and no error-laden transcript rather than returning
982
- # nothing.
983
- final_response = pre_text or self._direct_answer(user_prompt, effective_sys, history)
984
- if not final_response:
985
- final_response = "The computational analysis has been completed as detailed above."
986
- return AgentResult(
987
- final_response=final_response,
988
- tool_traces=traces,
989
- total_steps=step,
990
- raw_messages=messages,
991
- is_grounded=True,
992
- plots=plots,
993
- )
994
-
995
- if not tool_calls:
996
- # A fabrication refusal happened earlier this turn -- pre_text
997
- # here is exactly the kind of confident, ungrounded prose that
998
- # refusal was meant to prevent (observed directly: a blocked
999
- # LQR call still produced a fully invented gain in this same
1000
- # spot). Don't trust it at face value; fall through to the
1001
- # shared forced-synthesis turn below instead of returning,
1002
- # since that turn explicitly instructs against inventing a
1003
- # substitute value.
1004
- break
1005
-
1006
- # Drop repeats. An exact-signature check alone is not enough: the
1007
- # model will re-search with a lightly reworded query ("... MPC",
1008
- # "... MPC algorithm", "... MPC definition"), exhausting the step
1009
- # budget on near-identical lookups and leaving nothing for the
1010
- # answer. Cap how many times any single tool may run per turn.
1011
- new_calls = []
1012
- for call in tool_calls:
1013
- name = call.get("name")
1014
- sig = f"{name}:{json.dumps(call.get('arguments', {}), sort_keys=True)}"
1015
- if sig in called_signatures:
1016
- continue
1017
- if tool_call_counts.get(name, 0) >= MAX_CALLS_PER_TOOL:
1018
- continue
1019
- called_signatures.add(sig)
1020
- tool_call_counts[name] = tool_call_counts.get(name, 0) + 1
1021
- new_calls.append(call)
1022
-
1023
- if not new_calls:
1024
- break
1025
-
1026
- messages.append({"role": "assistant", "content": model_output})
1027
-
1028
- for call_data in new_calls:
1029
- tool_name = call_data.get("name")
1030
- tool_args = call_data.get("arguments", {})
1031
-
1032
- tool_result = self._execute_with_provenance(tool_name, tool_args, messages)
1033
- traces.append(ToolExecutionTrace(tool_name=tool_name, arguments=tool_args, result=tool_result))
1034
- if tool_result.get("error_type") == "FabricatedParameter":
1035
- fabrication_refusals.append(tool_result.get("error", ""))
1036
-
1037
- if "plot_path" in tool_result:
1038
- p_path = Path(tool_result["plot_path"])
1039
- if p_path.exists():
1040
- plots.append(f"/plots/{p_path.name}")
1041
-
1042
- messages.append({
1043
- "role": "tool",
1044
- "name": tool_name,
1045
- "content": json.dumps(tool_result, ensure_ascii=False),
1046
- })
1047
-
1048
- # Final synthesis after tool execution
1049
- messages.append({
1050
- "role": "user",
1051
- "content": (
1052
- "Now answer the user's most recent question directly and completely, in LaTeX-formatted "
1053
- "prose. If the tool results above are relevant to that question, incorporate them; if the "
1054
- "question is conceptual, definitional, or about what a source says and the tool results "
1055
- "above don't actually address it, answer the question from your own knowledge instead of "
1056
- "describing the tool results. Do not call any more tools and do not output JSON or "
1057
- "tool-call tags -- write the final answer now."
1058
- ) + _fabrication_refusal_note(fabrication_refusals),
1059
- })
1060
- forced_prompt = self.hf_tokenizer.apply_chat_template(
1061
- messages,
1062
- tools=None,
1063
- tokenize=False,
1064
- add_generation_prompt=True,
1065
- )
1066
- final_output = self._generate(forced_prompt, max_tokens=max_tokens_per_step)
1067
-
1068
- _, clean_final = _extract_tool_calls(final_output)
1069
- # Never fall back to the raw final_output: if the model's closing turn
1070
- # degenerates into another (unparseable) tool-call attempt, clean_final
1071
- # is stripped down to "" and showing final_output would leak a raw
1072
- # <tool_call>{...}</tool_call> JSON blob straight into the chat.
1073
- final_response = clean_final or self._direct_answer(user_prompt, effective_sys, history)
1074
- if not final_response:
1075
- # _direct_answer reliably produces real text when tested in
1076
- # isolation, so an empty result here is most likely a transient
1077
- # failure (resource contention, a stray exception) rather than a
1078
- # repeatable one -- one retry clears most of those before giving
1079
- # up and showing the placeholder.
1080
- final_response = self._direct_answer(user_prompt, effective_sys, history)
1081
- if not final_response:
1082
- final_response = "The computational analysis has been completed as detailed above."
1083
- return AgentResult(
1084
- final_response=final_response,
1085
- tool_traces=traces,
1086
- total_steps=len(traces) + 1,
1087
- raw_messages=messages,
1088
- is_grounded=True,
1089
- plots=plots,
1090
- )
1091
-
1092
- def run_stream(
1093
- self,
1094
- user_prompt: str,
1095
- system_instruction: str = CONTROLAI_SYSTEM_PROMPT,
1096
- history: list[dict[str, Any]] | None = None,
1097
- max_tokens_per_step: int = 2500,
1098
- ) -> Generator[dict[str, Any], None, None]:
1099
- """Stream token-by-token generation and tool execution events with zero JSON leakage."""
1100
- history = _truncate_history(history, self.hf_tokenizer)
1101
- messages: list[dict[str, Any]] = []
1102
- effective_sys = self._get_grounded_instruction(user_prompt, system_instruction)
1103
-
1104
- # RAG fast path: a conceptual/definitional question that already has
1105
- # strong grounded passages needs one generation, not a multi-step tool
1106
- # loop. See the _needs_tools docstring for why this is safe.
1107
- if effective_sys != system_instruction and not _needs_tools(user_prompt):
1108
- fast_answer = self._direct_answer(user_prompt, effective_sys, history)
1109
- if fast_answer:
1110
- words = re.split(r"(\s+)", fast_answer)
1111
- for w in words:
1112
- if w:
1113
- yield {"type": "token", "content": w}
1114
- yield {
1115
- "type": "done",
1116
- "response": fast_answer,
1117
- "traces": [],
1118
- "plots": [],
1119
- "thoughts": [],
1120
- }
1121
- return
1122
-
1123
- if effective_sys:
1124
- messages.append({"role": "system", "content": effective_sys})
1125
-
1126
- if history:
1127
- for item in history:
1128
- r = item.get("role")
1129
- c = item.get("content", "")
1130
- if r in ("user", "assistant") and c:
1131
- # Strip any legacy JSON tool call artifacts from previous chat sessions
1132
- c_clean = re.sub(r"\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*\}", "", c).strip()
1133
- c_clean = re.sub(r"<tool_call>[\s\S]*?</tool_call>", "", c_clean).strip()
1134
- if c_clean:
1135
- messages.append({"role": r, "content": c_clean})
1136
-
1137
- messages.append({"role": "user", "content": user_prompt})
1138
-
1139
- tools_schema = self.registry.get_tool_schemas()
1140
- traces: list[dict[str, Any]] = []
1141
- plots: list[str] = []
1142
- thoughts: list[str] = []
1143
- called_signatures: set[str] = set()
1144
- tool_call_counts: dict[str, int] = {}
1145
- fabrication_refusals: list[str] = []
1146
-
1147
- # Dynamic thought generation - only show thoughts when tools or derivations occur
1148
- for step in range(1, self.max_tool_steps + 1):
1149
- rendered_prompt = self.hf_tokenizer.apply_chat_template(
1150
- messages,
1151
- tools=tools_schema,
1152
- tokenize=False,
1153
- add_generation_prompt=True,
1154
- )
1155
-
1156
- model_output = self._generate(rendered_prompt, max_tokens=max_tokens_per_step)
1157
-
1158
- tool_calls, pre_text = _extract_tool_calls(model_output)
1159
-
1160
- if not tool_calls and not fabrication_refusals:
1161
- # Direct final answer without tools -> stream tokens directly
1162
- clean_output = pre_text or self._direct_answer(user_prompt, effective_sys, history)
1163
- if not clean_output:
1164
- clean_output = "The computational analysis has been completed as detailed above."
1165
- words = re.split(r"(\s+)", clean_output)
1166
- for w in words:
1167
- if w:
1168
- yield {"type": "token", "content": w}
1169
-
1170
- yield {
1171
- "type": "done",
1172
- "response": clean_output,
1173
- "traces": traces,
1174
- "plots": plots,
1175
- "thoughts": thoughts,
1176
- }
1177
- return
1178
-
1179
- if not tool_calls:
1180
- # A fabrication refusal happened earlier this turn -- pre_text
1181
- # here is exactly the kind of confident, ungrounded prose that
1182
- # refusal was meant to prevent. Don't trust it at face value;
1183
- # fall through to the shared forced-synthesis turn below,
1184
- # which explicitly instructs against inventing a substitute
1185
- # value, instead of streaming it straight to the user.
1186
- break
1187
-
1188
- if pre_text:
1189
- thoughts.append(pre_text)
1190
- yield {"type": "thought", "content": pre_text}
1191
-
1192
- # Drop repeats. An exact-signature check alone is not enough: the
1193
- # model will re-search with a lightly reworded query ("... MPC",
1194
- # "... MPC algorithm", "... MPC definition"), exhausting the step
1195
- # budget on near-identical lookups and leaving nothing for the
1196
- # answer. Cap how many times any single tool may run per turn.
1197
- new_calls = []
1198
- for call in tool_calls:
1199
- name = call.get("name")
1200
- sig = f"{name}:{json.dumps(call.get('arguments', {}), sort_keys=True)}"
1201
- if sig in called_signatures:
1202
- continue
1203
- if tool_call_counts.get(name, 0) >= MAX_CALLS_PER_TOOL:
1204
- continue
1205
- called_signatures.add(sig)
1206
- tool_call_counts[name] = tool_call_counts.get(name, 0) + 1
1207
- new_calls.append(call)
1208
-
1209
- if not new_calls:
1210
- break
1211
-
1212
- messages.append({"role": "assistant", "content": model_output})
1213
-
1214
- for call_data in new_calls:
1215
- tool_name = call_data.get("name")
1216
- tool_args = call_data.get("arguments", {})
1217
-
1218
- t_start_msg = f"Executing tool: {tool_name} with parameters: {json.dumps(tool_args, ensure_ascii=False)}"
1219
- thoughts.append(t_start_msg)
1220
- yield {"type": "thought", "content": t_start_msg}
1221
- yield {"type": "tool_start", "tool": tool_name, "args": tool_args}
1222
-
1223
- tool_result = self._execute_with_provenance(tool_name, tool_args, messages)
1224
- trace_item = {
1225
- "tool": tool_name,
1226
- "args": tool_args,
1227
- "status": tool_result.get("status", "success"),
1228
- "residual": tool_result.get("residual"),
1229
- }
1230
- traces.append(trace_item)
1231
- if tool_result.get("error_type") == "FabricatedParameter":
1232
- fabrication_refusals.append(tool_result.get("error", ""))
1233
-
1234
- if "plot_path" in tool_result:
1235
- p_path = Path(tool_result["plot_path"])
1236
- if p_path.exists():
1237
- plot_url = f"/plots/{p_path.name}"
1238
- plots.append(plot_url)
1239
- yield {"type": "plot", "url": plot_url}
1240
-
1241
- yield {"type": "tool_end", "trace": trace_item}
1242
- t_end_msg = f"Tool {tool_name} returned status: {trace_item['status']}"
1243
- thoughts.append(t_end_msg)
1244
- yield {"type": "thought", "content": t_end_msg}
1245
-
1246
- messages.append({
1247
- "role": "tool",
1248
- "name": tool_name,
1249
- "content": json.dumps(tool_result, ensure_ascii=False),
1250
- })
1251
-
1252
- # Final Synthesis phase
1253
- synth_thought = "Synthesizing verified engineering response and LaTeX formulations..."
1254
- thoughts.append(synth_thought)
1255
- yield {"type": "thought", "content": synth_thought}
1256
-
1257
- messages.append({
1258
- "role": "user",
1259
- "content": (
1260
- "Now answer the user's most recent question directly and completely, in LaTeX-formatted "
1261
- "prose. If the tool results above are relevant to that question, incorporate them; if the "
1262
- "question is conceptual, definitional, or about what a source says and the tool results "
1263
- "above don't actually address it, answer the question from your own knowledge instead of "
1264
- "describing the tool results. Do not call any more tools and do not output JSON or "
1265
- "tool-call tags -- write the final answer now."
1266
- ) + _fabrication_refusal_note(fabrication_refusals),
1267
- })
1268
-
1269
- forced_prompt = self.hf_tokenizer.apply_chat_template(
1270
- messages,
1271
- tools=None,
1272
- tokenize=False,
1273
- add_generation_prompt=True,
1274
- )
1275
-
1276
- final_output = self._generate(forced_prompt, max_tokens=max_tokens_per_step)
1277
-
1278
- # If model generated another tool call during final synthesis, execute it!
1279
- synth_tool_calls, clean_synth = _extract_tool_calls(final_output)
1280
- if synth_tool_calls:
1281
- for call_data in synth_tool_calls:
1282
- tool_name = call_data.get("name")
1283
- tool_args = call_data.get("arguments", {})
1284
- t_res = self._execute_with_provenance(tool_name, tool_args, messages)
1285
- if "plot_path" in t_res:
1286
- p_path = Path(t_res["plot_path"])
1287
- if p_path.exists():
1288
- plot_url = f"/plots/{p_path.name}"
1289
- plots.append(plot_url)
1290
- yield {"type": "plot", "url": plot_url}
1291
- messages.append({"role": "tool", "name": tool_name, "content": json.dumps(t_res, ensure_ascii=False)})
1292
-
1293
- # Re-generate synthesis after executing the tool
1294
- re_prompt = self.hf_tokenizer.apply_chat_template(messages, tools=None, tokenize=False, add_generation_prompt=True)
1295
- final_output = self._generate(re_prompt, max_tokens=max_tokens_per_step)
1296
- _, clean_synth = _extract_tool_calls(final_output)
1297
-
1298
- # Never fall back to the raw final_output here: if the model's closing
1299
- # turn degenerates into another (unparseable) tool-call attempt,
1300
- # clean_synth is stripped down to "" and showing final_output would
1301
- # leak a raw <tool_call>{...}</tool_call> JSON blob into the chat.
1302
- final_text = clean_synth
1303
- # Strip any lingering raw json
1304
- final_text = re.sub(r"\{\s*[\"']name[\"']\s*:[\s\S]*?\}\s*\}", "", final_text).strip()
1305
- if not final_text:
1306
- # A fresh, tool-free, minimal-context generation reliably produces
1307
- # real text even when the tool-heavy synthesis turn degenerated --
1308
- # try it (twice, for a transient failure) before the placeholder.
1309
- final_text = self._direct_answer(user_prompt, effective_sys, history)
1310
- if not final_text:
1311
- final_text = self._direct_answer(user_prompt, effective_sys, history)
1312
- if not final_text:
1313
- final_text = "The computational analysis and simulation have been executed successfully as detailed above."
1314
-
1315
- # Stream words smoothly
1316
- words = re.split(r"(\s+)", final_text)
1317
- for w in words:
1318
- if w:
1319
- yield {"type": "token", "content": w}
1320
-
1321
- yield {
1322
- "type": "done",
1323
- "response": final_text,
1324
- "traces": traces,
1325
- "plots": plots,
1326
- "thoughts": thoughts,
1327
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
controlai_agent/prompts.py CHANGED
@@ -1,42 +1,67 @@
1
- CONTROLAI_SYSTEM_PROMPT = r"""You are ControlAI, a premier AI research scientist and expert engineering agent for control systems engineering in ALL of its application domains -- aerospace and flight control, automotive and vehicle dynamics, robotics and motion control, industrial automation and PLC/process control, mechatronics, power and energy systems, marine and space vehicles -- together with the underlying applied mathematics, system identification, estimation, and dynamical systems theory.
2
-
3
- Treat every control question as in scope regardless of the industry it comes from, and answer it in the vocabulary of that domain: an autopilot question deserves flight-dynamics language (short-period mode, actuator rate limits, gain scheduling on airspeed), a vehicle question deserves automotive language (yaw rate, tire slip, ESP/ABS intervention), a robot question deserves manipulator/mobile-robot language (Jacobians, impedance control, odometry drift), and an automation question deserves plant language (loop tuning, valve saturation, cascade and ratio control, sensor filtering).
4
-
5
- ### Matching the Answer to the Question:
6
- - **Design / applied / "how does X work" questions**: lead with the engineering answer -- the architecture, the loop structure, the tuning trade-off, the sensors and actuators involved, the failure modes and safety interlocks. Use mathematics where it clarifies, not as ceremony.
7
- - **Numerical questions**: call the deterministic tools and report exact computed values.
8
- - **Theory, derivation, proof, comparison, and limitation questions**: use the 4-stage standard below.
9
- - **Definitional or "what does <author/textbook> say" questions**: answer from the retrieved reference passages and cite them; do not run a numeric solver.
10
-
11
- ### 4-Stage Mathematical Reasoning & Proof Standard:
12
- When answering theoretical principles, derivations, proofs, comparisons, or limitation questions:
13
- 1. **Mathematical Context & System Class**: State the state space equations (e.g. $\dot{x} = Ax + Bu, y = Cx + Du$ or $\dot{x} = f(x) + g(x)u$), signal spaces ($\mathcal{L}_2, \mathcal{H}_\infty$), domain definitions, and underlying assumptions.
14
- 2. **Canonical Theorem / Analytical Principle**: State the governing theorem with exact mathematical rigor (e.g. PBH rank test, Doyle 1978 LQG robustness counterexample, Poisson Integral for RHP zeros, Small Gain vs Passivity, Lyapunov Invariance).
15
- 3. **Exact Derivation & Closed-Form Formulas**: Provide the full, exact mathematical relationship in pure LaTeX (e.g. both the standard linear approximation $\zeta \approx PM/100$ and the exact non-linear relationship $PM = \arctan\left(\frac{2\zeta}{\sqrt{\sqrt{1+4\zeta^4}-2\zeta^2}}\right)$).
16
- 4. **Engineering Caveats & Breakdown Conditions**: Explicitly state where approximations break down (e.g. $PM > 60^\circ$ or high-frequency non-dominant dynamics), numerical ill-conditioning (e.g. high-order Kalman matrices vs PBH), and physical conservation trade-offs (e.g. Bode sensitivity integral / waterbed effect).
17
-
18
- ### Core Deterministic Capabilities & Tool Calling:
19
- - **State-feedback simulation (`simulate_state_feedback_response`)**: THE tool for "design a controller and simulate it". Pass the original `A`, `B` and the gain `K` returned by `continuous_lqr` / `discrete_lqr` / `place_state_feedback`; it forms the closed loop $A - BK$ internally and returns poles, damping, the exact closed-loop transfer function, transient metrics, and a plot. NEVER expand closed-loop polynomial coefficients by hand and feed them to `simulate_step_response` -- that algebra is the single most common source of silently wrong answers. Add `normalize_dc_gain: true` when the response should track a unit step.
20
- - **Transfer-function step response (`simulate_step_response`)**: only when the system is genuinely *given* as $G(s) = num/den$.
21
- - **Frequency domain**: `stability_margins` (GM/PM/crossovers), `bode_analysis`, `nyquist_analysis` (encirclements and the $Z = N + P$ criterion), `root_locus_analysis` (asymptotes, breakaway points, critical gain at instability).
22
- - Python Code Execution (`execute_python_code`): Write valid Python scripts using `scipy.signal`, `scipy.linalg`, `control` (`import control as ct`), `numpy`, `matplotlib.pyplot`. (Note: `T` in `signal.step` must be a 1D array like `np.linspace(...)`). Prefer a dedicated tool above when one fits -- it is verified and cannot be mis-transcribed. Only reach for this when the question actually requires a number, a simulation, or a plot for a SPECIFIC system. A "design considerations for X", "how does X compare to Y", or "explain X" question with no concrete numbers in it does not need code -- answer in prose. Writing code for a question that doesn't need it only risks a wasted tool step on a shape/dimension mistake with nothing to show for it.
23
- - **Plain matrix math (`matrix_arithmetic`)**: "A times B", inverse, determinant, rank, transpose, eigenvalues of a given matrix. A request to multiply or invert matrices is a LINEAR ALGEBRA question -- it is not an invitation to design a controller, and it needs no B, Q, or R.
24
- - Deterministic Math Tools: `continuous_lqr`, `discrete_lqr`, `place_state_feedback`, `exact_zoh`, `eigen_analysis`, `controllability_analysis`, `observability_analysis`, `solve_lyapunov`, `mpc_solve_qp`, `kalman_*`.
25
- - `plot_math_expression` plots a REAL function of one real variable ($t$ or $x$) only. Never pass a transfer function, a Laplace-domain expression, or anything containing the imaginary unit to it.
26
- - Reference Lookup (`search_control_references`): Use this -- not a numeric tool -- for conceptual, definitional, or "how does textbook/author X explain Y" questions. A question about what a concept means or how a source presents it is never a reason to (re-)run stability_margins, bode_analysis, routh_hurwitz_analysis, or any other numeric solver. If retrieved reference passages are already provided in this system prompt, answer straight from them and cite `[filename, p. N]`.
27
- - **One lookup is enough**: after a reference search returns passages, ANSWER from them. Do not re-run the same search with a reworded query ("X", then "X algorithm", then "X definition") -- repeated lookups consume the step budget and leave nothing for the answer itself. If the retrieved passages are thin, answer from your own knowledge and say what is uncertain.
28
- - **Don't recompute what's already in the conversation**: if a transfer function's margins, poles, or response were already computed earlier in this conversation, reuse those results instead of calling the same numeric tool again for a follow-up question about something else. Answer the question that was actually asked.
29
- - **Coefficient care**: when you must expand a factored transfer function like $s(s+1)(s+5)$ into polynomial form, expand one factor at a time and re-check each coefficient -- the deterministic tools verify their own arithmetic, not the coefficients you hand them.
30
- - **NEVER invent a missing parameter, ever -- not a reused one, not a new one, not a "reasonable-looking" one.** Every number you pass to a tool -- every matrix, gain, coefficient -- must come from the user's CURRENT message, from a tool result already in this conversation, or from a standard formula you can name. If a computation needs a parameter (e.g. B, Q, R for LQR) that is not present anywhere and not derivable, STOP. Do not guess a plausible value. Do not carry one over, resized or not, from a different problem earlier in the conversation. Do not make one up because a shape needs to match. Tell the user exactly which parameter is missing and ask for it, or answer only the part of the question you actually can with what was given. A short, honest "I don't have B for this system -- what is it?" is the correct answer. A confident computation built on an invented number is not a partial answer, it is a fabricated one, and it is the single worst thing you can do in this domain. If you catch yourself about to write a matrix or number that did not come from the message, the history, or a named formula, that is the signal to stop and ask instead of proceeding.
31
- - **A confusing or malformed message is a request for clarification, not a license to substitute a different, cleaner-looking problem.** If the user's wording is ambiguous (e.g. it could mean matrix multiplication, or could mean a controller design, and you cannot tell which), say what you think they might mean and ask, rather than silently picking one interpretation and inventing whatever inputs that interpretation requires.
32
-
33
- ### Strict Negative & Formatting Constraints:
34
- 1. **ZERO EMOJIS**: NEVER use any emojis anywhere in your response (absolutely NO checkmarks, NO pins, NO graphs, NO rockets, NO lightbulbs).
35
- 2. **ALWAYS USE DOLLAR DELIMITERS FOR MATH**: ALWAYS enclose EVERY mathematical formula, transfer function, variable, fraction, and Greek letter in dollar signs: `$ ... $` for inline math or `$$ ... $$` for centered equations. Example: `$$G(s) = \frac{1}{s(s+1)(s+2)}$$` and `$\omega \to \infty$`. NEVER write raw LaTeX keywords (`\frac`, `\omega`, `\to`) without `$` or `$$` delimiters!
36
- 2b. **SINGLE BACKSLASH ONLY**: Every LaTeX command starts with exactly one backslash character, as in `\zeta`, `\left(`, `\sqrt{}`, `\sin`. Count the backslashes before you write a command and stop at one.
37
- 3. **NO RAW LATEX DOCUMENT TAGS**: NEVER output `\begin{figure}`, `\includegraphics`, `\caption`, `\centering`, `\section*`, or `\end{figure}`. Use standard markdown headers (e.g. `### Section Title`).
38
- 4. **MANDATORY TOOL CALL FOR PLOTS**: When asked to plot, visualize, or simulate (Nyquist plot, Bode diagram, Root Locus, Step Response, Phase Portrait), NEVER hallucinate a fake image filename. You MUST explicitly call `execute_python_code` (using `control as ct` or `matplotlib.pyplot`) or `simulate_step_response` to generate and display the real plot!
39
- 5. **Deterministic Grounding**: When a tool executes successfully, present the exact numerical results and generated plot.
40
- 6. **No Infinite Retries, but READ the error first**: If a tool returns an error, the error text itself is often the entire answer to what's actually being asked -- "uncontrollable", "singular", "poles can't be placed", "not positive semi-definite" name the exact concept the question is testing. Before doing anything else: quote or restate that reason in your final answer as the headline finding, in plain engineering language (e.g. "this system is uncontrollable because..."). Only after stating it should you correct parameters and retry, or move on. NEVER silently produce a numeric-looking final answer (a bare $$K = ...$$, a computed matrix) that routes around an error without ever mentioning what it said -- an ignored error is a worse answer than no answer.
41
- 7. **No Forced Citations**: Do NOT cite arbitrary random file paths unless the user explicitly requests literature references.
42
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """System prompts for ControlAI.
2
+
3
+ The previous prompt was 2,587 tokens of mostly prohibitions written in shouted
4
+ capitals. It cost real latency on every single turn, and on a small model the
5
+ prohibitions backfired: the "never invent a parameter" clause taught the model
6
+ to refuse worked examples, which is the single most useful thing a teaching
7
+ assistant does. This one is short, states what to do rather than what not to
8
+ do, and leaves the genuinely structural guarantees (schema validation, result
9
+ rounding, independent verification) to code where they belong.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  """
11
+
12
+ SYSTEM_PROMPT = """You are ControlAI, an expert control systems engineer. You cover classical and \
13
+ modern control, state-space methods, optimal and robust control, estimation, nonlinear and \
14
+ adaptive control, and system identification, across aerospace, automotive, robotics, process \
15
+ automation, power systems, and mechatronics. Answer in the vocabulary of whichever domain the \
16
+ question comes from.
17
+
18
+ ## Using tools
19
+
20
+ You have deterministic solvers (SciPy/LAPACK/CVXPY). Every number you state must come from one \
21
+ of them or from the user -- never compute a Riccati solution, a set of closed-loop poles, a gain \
22
+ margin, or a polynomial expansion in your head.
23
+
24
+ - Call a tool when the question involves a concrete system and needs a number, a matrix, or a plot.
25
+ - Answer directly, with no tool call, when the question is conceptual, definitional, comparative, \
26
+ or a derivation. Explaining what a phase margin *is* needs prose, not a solver.
27
+ - When a system is given in factored form such as $G(s) = K/(s(s+1)(s+5))$, use \
28
+ `expand_polynomial_from_roots` to get the coefficients rather than multiplying the factors out \
29
+ yourself. Expand the denominator roots with `gain: 1` and pass $K$ as the numerator.
30
+ - For gain and phase margins call `stability_margins`, which returns them along with both \
31
+ crossover frequencies. `bode_analysis` returns a magnitude and phase curve; do not read margins \
32
+ off it by eye.
33
+ - To design a controller and then see its behaviour, pass the original $A$, $B$ and the returned \
34
+ gain $K$ to `simulate_state_feedback_response`; it closes the loop internally.
35
+ - Do not form $A - BK$, multiply matrices, or expand a characteristic polynomial by hand when \
36
+ writing up a result. Hand algebra in the write-up is where a correct solver output turns into a \
37
+ wrong answer. The LQR and pole-placement tools already return `closed_loop_A` -- quote that. \
38
+ Otherwise use `matrix_arithmetic`, or state what the solver returned and stop there.
39
+ - If a tool returns an error, that error is usually the answer: "uncontrollable", "singular", \
40
+ "not stabilizable" name the exact property the question is about. Lead with it in plain language.
41
+
42
+ ## Worked examples
43
+
44
+ When the user asks for an example, a demonstration, or "show me how this works" without giving a \
45
+ system, choose a clean illustrative one yourself, say plainly that you are choosing it, and run \
46
+ the real tools on it. A concrete worked example is the correct answer to that request.
47
+
48
+ When the user asks about *their* system but a parameter you need is genuinely missing, ask for \
49
+ that one parameter. Do not silently substitute a value and present the result as theirs.
50
+
51
+ ## Style
52
+
53
+ Lead with the engineering answer: the loop structure, the trade-off, the number that was asked \
54
+ for. Add derivations when they clarify. State where an approximation breaks down.
55
+
56
+ Write all mathematics in LaTeX delimited by `$...$` inline or `$$...$$` displayed, using single \
57
+ backslashes. Use markdown headings, never LaTeX document commands. Do not use emoji."""
58
+
59
+
60
+ RETRIEVAL_PREAMBLE = """The following passages were retrieved from the local control-engineering \
61
+ library for this question. Where a passage covers the question, answer from it and cite it with \
62
+ the bracketed label exactly as shown. Where it does not, ignore it and answer from your own \
63
+ knowledge -- do not force a citation, and never print a raw filename."""
64
+
65
+
66
+ SYNTHESIS_NUDGE = """Write the final answer now, using the tool results above. State the computed \
67
+ values explicitly. Do not call any more tools."""
controlai_agent/toolcall.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parsing of Qwen-style `<tool_call>` blocks out of a model response.
2
+
3
+ The previous implementation carried several hundred lines of regex repair --
4
+ unbalanced-brace closing, space-separated MATLAB array rewriting, LaTeX
5
+ backslash unescaping. Almost all of it existed to compensate for a fine-tuned
6
+ adapter that emitted malformed JSON; the base model emits well-formed calls, so
7
+ what remains here is a small, readable tolerance margin rather than a repair
8
+ pipeline. Argument-level coercion (stringified arrays and the like) already
9
+ happens in `registry.execute`, so it is deliberately not duplicated here.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ TOOL_CALL_RE = re.compile(r"<tool_call>\s*(.*?)\s*(?:</tool_call>|$)", re.DOTALL)
20
+ # Some checkpoints emit a bare JSON object with no wrapping tags at all.
21
+ BARE_CALL_RE = re.compile(r'\{\s*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{.*?\}\s*\}', re.DOTALL)
22
+
23
+
24
+ @dataclass
25
+ class ToolCall:
26
+ name: str
27
+ arguments: dict[str, Any] = field(default_factory=dict)
28
+
29
+
30
+ def _close_json(raw: str) -> str:
31
+ """Append the brackets needed to balance a truncated JSON object.
32
+
33
+ Generation can hit the token limit mid-call. Closing the structure recovers
34
+ a usable call instead of discarding one that was merely cut short.
35
+ """
36
+ in_string = escaped = False
37
+ stack: list[str] = []
38
+ for ch in raw:
39
+ if escaped:
40
+ escaped = False
41
+ continue
42
+ if ch == "\\":
43
+ escaped = True
44
+ elif ch == '"':
45
+ in_string = not in_string
46
+ elif not in_string:
47
+ if ch in "{[":
48
+ stack.append(ch)
49
+ elif ch in "}]" and stack:
50
+ stack.pop()
51
+ return raw + ('"' if in_string else "") + "".join("}" if c == "{" else "]" for c in reversed(stack))
52
+
53
+
54
+ def _loads(raw: str) -> dict[str, Any] | None:
55
+ for candidate in (raw, _close_json(raw)):
56
+ try:
57
+ parsed = json.loads(candidate)
58
+ except (json.JSONDecodeError, TypeError):
59
+ continue
60
+ if isinstance(parsed, dict) and parsed.get("name"):
61
+ return parsed
62
+ return None
63
+
64
+
65
+ def parse(text: str) -> tuple[list[ToolCall], str]:
66
+ """Split a response into its tool calls and its prose.
67
+
68
+ Returns `(calls, prose)`. An empty `calls` list means the response was a
69
+ direct answer.
70
+ """
71
+ calls: list[ToolCall] = []
72
+ for block in TOOL_CALL_RE.findall(text):
73
+ parsed = _loads(block.strip())
74
+ if parsed:
75
+ args = parsed.get("arguments") or parsed.get("parameters") or {}
76
+ if isinstance(args, str):
77
+ args = _loads(args) or {}
78
+ calls.append(ToolCall(name=str(parsed["name"]), arguments=args if isinstance(args, dict) else {}))
79
+
80
+ prose = TOOL_CALL_RE.sub("", text)
81
+ if not calls:
82
+ for block in BARE_CALL_RE.findall(prose):
83
+ parsed = _loads(block)
84
+ if parsed:
85
+ args = parsed.get("arguments") or {}
86
+ calls.append(ToolCall(name=str(parsed["name"]), arguments=args if isinstance(args, dict) else {}))
87
+ prose = prose.replace(block, "")
88
+
89
+ prose = re.sub(r"<think>.*?</think>", "", prose, flags=re.DOTALL)
90
+ prose = re.sub(r"</?(?:think|tool_call|tool_response)>", "", prose)
91
+ return calls, prose.strip()
92
+
93
+
94
+ _MAX_ARRAY_LEN = 400
95
+ _MAX_IDENTICAL_RUN = 12
96
+
97
+
98
+ def degenerate_reason(value: Any, depth: int = 0) -> str | None:
99
+ """Detect runaway repetition in a tool argument.
100
+
101
+ A decoding loop can produce a several-hundred-element array of the same
102
+ number, which is never a real system and wastes a step plus a large slice
103
+ of context if it is executed. The thresholds are set well above any
104
+ plausible hand-written matrix so ordinary inputs are never touched.
105
+ """
106
+ if depth > 4 or not isinstance(value, list):
107
+ return None
108
+ if len(value) > _MAX_ARRAY_LEN:
109
+ return f"has {len(value)} elements"
110
+ run = 1
111
+ for prev, cur in zip(value, value[1:]):
112
+ run = run + 1 if prev == cur and isinstance(cur, (int, float)) else 1
113
+ if run >= _MAX_IDENTICAL_RUN:
114
+ return f"repeats the value {cur} {run} times in a row"
115
+ for item in value:
116
+ reason = degenerate_reason(item, depth + 1)
117
+ if reason:
118
+ return reason
119
+ return None
controlai_agent/tools.py DELETED
@@ -1,427 +0,0 @@
1
- """Deterministic control engineering tools backed by NumPy, SciPy, and CVXPY."""
2
-
3
- from __future__ import annotations
4
-
5
- import math
6
- from typing import Any
7
-
8
- import numpy as np
9
- from scipy import linalg, signal
10
-
11
- from controlai_agent.registry import registry
12
-
13
-
14
- @registry.register(
15
- name="exact_zoh",
16
- description="Discretize continuous-time state-space matrices (A, B) under exact Zero-Order Hold (ZOH) at sample time Ts.",
17
- parameters_schema={
18
- "type": "object",
19
- "properties": {
20
- "A": {
21
- "type": "array",
22
- "items": {"type": "array", "items": {"type": "number"}},
23
- "description": "Continuous system matrix A (n x n)",
24
- },
25
- "B": {
26
- "type": "array",
27
- "items": {"type": "array", "items": {"type": "number"}},
28
- "description": "Continuous input matrix B (n x m)",
29
- },
30
- "Ts": {
31
- "type": "number",
32
- "description": "Sample time in seconds (Ts > 0)",
33
- },
34
- },
35
- "required": ["A", "B", "Ts"],
36
- },
37
- )
38
- def exact_zoh(A: list[list[float]], B: list[list[float]], Ts: float) -> dict[str, Any]:
39
- A_mat = np.array(A, dtype=float)
40
- B_mat = np.array(B, dtype=float)
41
- n = A_mat.shape[0]
42
- m = B_mat.shape[1] if B_mat.ndim > 1 else 1
43
- C_dummy = np.eye(n)
44
- D_dummy = np.zeros((n, m))
45
- Ad, Bd, _, _, _ = signal.cont2discrete((A_mat, B_mat, C_dummy, D_dummy), Ts, method="zoh")
46
- return {
47
- "Ad": Ad.tolist(),
48
- "Bd": Bd.tolist(),
49
- "sample_time": Ts,
50
- "method": "exact_zoh",
51
- }
52
-
53
-
54
- @registry.register(
55
- name="eigen_analysis",
56
- description="Compute eigenvalues, eigenvectors, and asymptotic stability of a continuous or discrete system matrix A.",
57
- parameters_schema={
58
- "type": "object",
59
- "properties": {
60
- "A": {
61
- "type": "array",
62
- "items": {"type": "array", "items": {"type": "number"}},
63
- "description": "System matrix A (n x n)",
64
- },
65
- "discrete": {
66
- "type": "boolean",
67
- "description": "True if discrete-time (criterion |lambda| < 1), False if continuous (Re(lambda) < 0)",
68
- },
69
- },
70
- "required": ["A"],
71
- },
72
- )
73
- def eigen_analysis(A: list[list[float]], discrete: bool = False) -> dict[str, Any]:
74
- A_mat = np.array(A, dtype=float)
75
- eigenvalues = np.linalg.eigvals(A_mat)
76
- if discrete:
77
- stable = bool(np.all(np.abs(eigenvalues) < 1.0))
78
- criterion = "|lambda_i| < 1"
79
- else:
80
- stable = bool(np.all(np.real(eigenvalues) < 0.0))
81
- criterion = "Re(lambda_i) < 0"
82
-
83
- formatted_eigs = []
84
- for eig in eigenvalues:
85
- if abs(eig.imag) < 1e-9:
86
- formatted_eigs.append(f"{eig.real:.6g}")
87
- else:
88
- sign = "+" if eig.imag >= 0 else "-"
89
- formatted_eigs.append(f"{eig.real:.6g} {sign} {abs(eig.imag):.6g}j")
90
-
91
- return {
92
- "eigenvalues": [[float(e.real), float(e.imag)] for e in eigenvalues],
93
- "eigenvalues_formatted": formatted_eigs,
94
- "is_stable": stable,
95
- "criterion": criterion,
96
- "time_domain": "discrete" if discrete else "continuous",
97
- }
98
-
99
-
100
- @registry.register(
101
- name="controllability_analysis",
102
- description="Compute controllability matrix, rank, and PBH modal controllability test for pair (A, B).",
103
- parameters_schema={
104
- "type": "object",
105
- "properties": {
106
- "A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
107
- "B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
108
- },
109
- "required": ["A", "B"],
110
- },
111
- )
112
- def controllability_analysis(A: list[list[float]], B: list[list[float]]) -> dict[str, Any]:
113
- A_mat = np.array(A, dtype=float)
114
- B_mat = np.array(B, dtype=float)
115
- n = A_mat.shape[0]
116
- blocks = [B_mat]
117
- current = B_mat
118
- for _ in range(1, n):
119
- current = A_mat @ current
120
- blocks.append(current)
121
- C_mat = np.hstack(blocks)
122
- rank = int(np.linalg.matrix_rank(C_mat))
123
- is_controllable = rank == n
124
- return {
125
- "controllability_matrix": C_mat.tolist(),
126
- "rank": rank,
127
- "state_dimension": n,
128
- "is_controllable": is_controllable,
129
- }
130
-
131
-
132
- @registry.register(
133
- name="observability_analysis",
134
- description="Compute observability matrix, rank, and PBH modal observability test for pair (A, C).",
135
- parameters_schema={
136
- "type": "object",
137
- "properties": {
138
- "A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
139
- "C": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
140
- },
141
- "required": ["A", "C"],
142
- },
143
- )
144
- def observability_analysis(A: list[list[float]], C: list[list[float]]) -> dict[str, Any]:
145
- A_mat = np.array(A, dtype=float)
146
- C_mat = np.array(C, dtype=float)
147
- n = A_mat.shape[0]
148
- blocks = [C_mat]
149
- current = C_mat
150
- for _ in range(1, n):
151
- current = current @ A_mat
152
- blocks.append(current)
153
- O_mat = np.vstack(blocks)
154
- rank = int(np.linalg.matrix_rank(O_mat))
155
- is_observable = rank == n
156
- return {
157
- "observability_matrix": O_mat.tolist(),
158
- "rank": rank,
159
- "state_dimension": n,
160
- "is_observable": is_observable,
161
- }
162
-
163
-
164
- @registry.register(
165
- name="continuous_lqr",
166
- description="Solve Continuous-time Linear Quadratic Regulator (CARE) problem: min integral (x^T Q x + u^T R u) dt.",
167
- parameters_schema={
168
- "type": "object",
169
- "properties": {
170
- "A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
171
- "B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
172
- "Q": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
173
- "R": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
174
- },
175
- "required": ["A", "B", "Q", "R"],
176
- },
177
- )
178
- def continuous_lqr(
179
- A: list[list[float]], B: list[list[float]], Q: list[list[float]], R: list[list[float]]
180
- ) -> dict[str, Any]:
181
- A_mat = np.array(A, dtype=float)
182
- B_mat = np.array(B, dtype=float)
183
- Q_mat = np.array(Q, dtype=float)
184
- R_mat = np.array(R, dtype=float)
185
- P = linalg.solve_continuous_are(A_mat, B_mat, Q_mat, R_mat)
186
- K = np.linalg.solve(R_mat, B_mat.T @ P)
187
- A_cl = A_mat - B_mat @ K
188
- poles = np.linalg.eigvals(A_cl)
189
- residual = float(np.max(np.abs(A_mat.T @ P + P @ A_mat - P @ B_mat @ np.linalg.inv(R_mat) @ B_mat.T @ P + Q_mat)))
190
- return {
191
- "P": P.tolist(),
192
- "K": K.tolist(),
193
- "closed_loop_poles": [[float(p.real), float(p.imag)] for p in poles],
194
- "is_stable": bool(np.all(np.real(poles) < 0)),
195
- "riccati_residual": residual,
196
- }
197
-
198
-
199
- @registry.register(
200
- name="discrete_lqr",
201
- description="Solve Discrete-time Linear Quadratic Regulator (DARE) problem: min sum (x_k^T Q x_k + u_k^T R u_k).",
202
- parameters_schema={
203
- "type": "object",
204
- "properties": {
205
- "A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
206
- "B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
207
- "Q": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
208
- "R": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
209
- },
210
- "required": ["A", "B", "Q", "R"],
211
- },
212
- )
213
- def discrete_lqr(
214
- A: list[list[float]], B: list[list[float]], Q: list[list[float]], R: list[list[float]]
215
- ) -> dict[str, Any]:
216
- A_mat = np.array(A, dtype=float)
217
- B_mat = np.array(B, dtype=float)
218
- Q_mat = np.array(Q, dtype=float)
219
- R_mat = np.array(R, dtype=float)
220
- P = linalg.solve_discrete_are(A_mat, B_mat, Q_mat, R_mat)
221
- K = np.linalg.solve(R_mat + B_mat.T @ P @ B_mat, B_mat.T @ P @ A_mat)
222
- A_cl = A_mat - B_mat @ K
223
- poles = np.linalg.eigvals(A_cl)
224
- return {
225
- "P": P.tolist(),
226
- "K": K.tolist(),
227
- "closed_loop_poles": [[float(p.real), float(p.imag)] for p in poles],
228
- "is_stable": bool(np.all(np.abs(poles) < 1.0)),
229
- }
230
-
231
-
232
- @registry.register(
233
- name="cbf_safety_filter",
234
- description="Scalar Control Barrier Function (CBF) quadratic program safety filter for single integrator x_dot=u with constraint x >= x_min.",
235
- parameters_schema={
236
- "type": "object",
237
- "properties": {
238
- "x": {"type": "number", "description": "Current state"},
239
- "u_nom": {"type": "number", "description": "Nominal desired control input"},
240
- "alpha": {"type": "number", "description": "CBF class-K gain parameter"},
241
- "x_min": {"type": "number", "description": "Safety lower bound x >= x_min"},
242
- },
243
- "required": ["x", "u_nom", "alpha", "x_min"],
244
- },
245
- )
246
- def cbf_safety_filter(x: float, u_nom: float, alpha: float, x_min: float) -> dict[str, Any]:
247
- h = x - x_min
248
- lower_bound = -alpha * h
249
- u_safe = max(u_nom, lower_bound)
250
- active = bool(u_nom < lower_bound)
251
- return {
252
- "h": h,
253
- "cbf_lower_bound": lower_bound,
254
- "u_safe": u_safe,
255
- "is_constraint_active": active,
256
- "h_dot_plus_alpha_h": u_safe + alpha * h,
257
- }
258
-
259
-
260
- @registry.register(
261
- name="dynamic_inversion",
262
- description="Compute exact Nonlinear Dynamic Inversion (NDI) input u for scalar affine system x_dot = a*x + b*u to track target error dynamics x_dot = -gain*(x - r).",
263
- parameters_schema={
264
- "type": "object",
265
- "properties": {
266
- "a": {"type": "number", "description": "Open-loop plant coefficient a"},
267
- "b": {"type": "number", "description": "Control effectiveness coefficient b (b != 0)"},
268
- "x": {"type": "number", "description": "Current state x"},
269
- "reference": {"type": "number", "description": "Setpoint reference r"},
270
- "gain": {"type": "number", "description": "Tracking convergence gain k > 0"},
271
- },
272
- "required": ["a", "b", "x", "reference", "gain"],
273
- },
274
- )
275
- def dynamic_inversion(a: float, b: float, x: float, reference: float, gain: float) -> dict[str, Any]:
276
- virtual_control = -gain * (x - reference)
277
- u = (virtual_control - a * x) / b
278
- x_dot = a * x + b * u
279
- return {
280
- "virtual_control_v": virtual_control,
281
- "control_input_u": u,
282
- "achieved_x_dot": x_dot,
283
- "residual": abs(x_dot - virtual_control),
284
- }
285
-
286
-
287
- @registry.register(
288
- name="kharitonov_stability_test",
289
- description="Evaluate robust Hurwitz stability of a real interval polynomial using Kharitonov's Theorem (tests the 4 extreme polynomials).",
290
- parameters_schema={
291
- "type": "object",
292
- "properties": {
293
- "lower_bounds": {
294
- "type": "array",
295
- "items": {"type": "number"},
296
- "description": "Lower coefficient bounds [a0^-, a1^-, a2^-, a3^-] in ascending powers",
297
- },
298
- "upper_bounds": {
299
- "type": "array",
300
- "items": {"type": "number"},
301
- "description": "Upper coefficient bounds [a0^+, a1^+, a2^+, a3^+] in ascending powers",
302
- },
303
- },
304
- "required": ["lower_bounds", "upper_bounds"],
305
- },
306
- )
307
- def kharitonov_stability_test(lower_bounds: list[float], upper_bounds: list[float]) -> dict[str, Any]:
308
- lows = np.array(lower_bounds, dtype=float)
309
- highs = np.array(upper_bounds, dtype=float)
310
- polys = [
311
- [lows[0], lows[1], highs[2], highs[3]],
312
- [highs[0], highs[1], lows[2], lows[3]],
313
- [highs[0], lows[1], lows[2], highs[3]],
314
- [lows[0], highs[1], highs[2], lows[3]],
315
- ]
316
- pole_sets = []
317
- stable_flags = []
318
- for poly in polys:
319
- desc_poly = poly[::-1] # descending for np.roots
320
- roots = np.roots(desc_poly)
321
- pole_sets.append([[float(r.real), float(r.imag)] for r in roots])
322
- stable_flags.append(bool(np.all(np.real(roots) < 0)))
323
-
324
- robustly_stable = all(stable_flags)
325
- return {
326
- "kharitonov_polynomials_ascending": polys,
327
- "stable_flags": stable_flags,
328
- "is_robustly_hurwitz": robustly_stable,
329
- "poles": pole_sets,
330
- }
331
-
332
-
333
- @registry.register(
334
- name="minimum_norm_control_allocation",
335
- description="Compute minimum 2-norm control allocation for redundant actuators: min ||u||_2 subject to B*u = tau.",
336
- parameters_schema={
337
- "type": "object",
338
- "properties": {
339
- "B": {
340
- "type": "array",
341
- "items": {"type": "number"},
342
- "description": "Actuator effectiveness row vector B (1 x m)",
343
- },
344
- "desired_tau": {
345
- "type": "number",
346
- "description": "Desired virtual control torque/force tau",
347
- },
348
- },
349
- "required": ["B", "desired_tau"],
350
- },
351
- )
352
- def minimum_norm_control_allocation(B: list[float], desired_tau: float) -> dict[str, Any]:
353
- B_vec = np.array(B, dtype=float)
354
- b_norm_sq = float(np.dot(B_vec, B_vec))
355
- u = (desired_tau / b_norm_sq) * B_vec
356
- achieved = float(np.dot(B_vec, u))
357
- return {
358
- "u": u.tolist(),
359
- "achieved_tau": achieved,
360
- "residual": abs(achieved - desired_tau),
361
- "norm_u": float(np.linalg.norm(u)),
362
- }
363
-
364
-
365
- @registry.register(
366
- name="jump_system_stability",
367
- description="Compute second-moment contraction factor E[a^2] = sum(p_i * a_i^2) for i.i.d. scalar jump linear systems.",
368
- parameters_schema={
369
- "type": "object",
370
- "properties": {
371
- "probabilities": {
372
- "type": "array",
373
- "items": {"type": "number"},
374
- "description": "Probabilities p_i summing to 1",
375
- },
376
- "multipliers": {
377
- "type": "array",
378
- "items": {"type": "number"},
379
- "description": "Multipliers a_i for each jump mode",
380
- },
381
- },
382
- "required": ["probabilities", "multipliers"],
383
- },
384
- )
385
- def jump_system_stability(probabilities: list[float], multipliers: list[float]) -> dict[str, Any]:
386
- p = np.array(probabilities, dtype=float)
387
- a = np.array(multipliers, dtype=float)
388
- expected_square = float(np.sum(p * (a**2)))
389
- is_stable = expected_square < 1.0
390
- return {
391
- "expected_squared_multiplier": expected_square,
392
- "is_mean_square_stable": is_stable,
393
- "contraction_margin": 1.0 - expected_square,
394
- }
395
-
396
-
397
- @registry.register(
398
- name="place_state_feedback",
399
- description="Compute state feedback gain matrix K such that eig(A - B*K) matches target desired poles.",
400
- parameters_schema={
401
- "type": "object",
402
- "properties": {
403
- "A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
404
- "B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
405
- "desired_poles": {
406
- "type": "array",
407
- "items": {"type": "number"},
408
- "description": "Target closed-loop pole locations",
409
- },
410
- },
411
- "required": ["A", "B", "desired_poles"],
412
- },
413
- )
414
- def place_state_feedback(
415
- A: list[list[float]], B: list[list[float]], desired_poles: list[float]
416
- ) -> dict[str, Any]:
417
- A_mat = np.array(A, dtype=float)
418
- B_mat = np.array(B, dtype=float)
419
- des = np.array(desired_poles, dtype=float)
420
- placed = signal.place_poles(A_mat, B_mat, des)
421
- K = placed.gain_matrix
422
- closed_poles = np.linalg.eigvals(A_mat - B_mat @ K)
423
- return {
424
- "K": K.tolist(),
425
- "closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_poles],
426
- "target_poles": desired_poles,
427
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
controlai_agent/tools/frequency.py CHANGED
@@ -17,9 +17,58 @@ from controlai_agent.registry import registry
17
  ARTIFACT_DIR = Path("outputs/plots")
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  @registry.register(
21
  name="bode_analysis",
22
- description="Compute frequency response magnitude (linear & dB), phase (degrees), and bandwidth for transfer function G(s) = num(s) / den(s).",
 
 
 
 
23
  parameters_schema={
24
  "type": "object",
25
  "properties": {
@@ -63,6 +112,13 @@ def bode_analysis(
63
  "frequencies_sample": w_out[::10].tolist(),
64
  "magnitudes_db_sample": mag_db[::10].tolist(),
65
  "phases_deg_sample": phase_deg[::10].tolist(),
 
 
 
 
 
 
 
66
  }
67
 
68
 
 
17
  ARTIFACT_DIR = Path("outputs/plots")
18
 
19
 
20
+ @registry.register(
21
+ name="expand_polynomial_from_roots",
22
+ description=(
23
+ "Expand a transfer function or characteristic polynomial given in factored/root form -- "
24
+ "e.g. G(s) = K / (s*(s+1)*(s+5)), roots at s = 0, -1, -5 -- into exact polynomial "
25
+ "coefficients in descending powers. ALWAYS call this instead of multiplying the factors "
26
+ "out by hand before calling bode_analysis, stability_margins, simulate_step_response, "
27
+ "routh_hurwitz_analysis, root_locus, or any other tool that takes numerator/denominator "
28
+ "coefficients: hand-expanding factors is the single most common source of a silently "
29
+ "wrong tool call, since nothing downstream can verify an argument that was already wrong "
30
+ "going in. A factor '(s + a)' contributes the root -a; '(s - a)' contributes root a."
31
+ ),
32
+ parameters_schema={
33
+ "type": "object",
34
+ "properties": {
35
+ "roots": {
36
+ "type": "array",
37
+ "items": {"type": "number"},
38
+ "description": "The roots of the polynomial, one per linear factor -- e.g. [0, -1, -5] for s*(s+1)*(s+5)",
39
+ },
40
+ "gain": {
41
+ "type": "number",
42
+ "default": 1.0,
43
+ "description": (
44
+ "Multiplies every returned coefficient. Leave this at 1 when expanding a "
45
+ "DENOMINATOR -- the overall constant K belongs in the numerator, not scaled "
46
+ "into the denominator. For G(s) = 10/(s(s+1)(s+5)), expand roots [0,-1,-5] "
47
+ "with gain 1 to get the denominator [1,6,5,0] and pass numerator [10] "
48
+ "separately. Passing gain=10 here instead yields [10,60,50,0], which is the "
49
+ "same transfer function scaled by 1/10 and silently wrong."
50
+ ),
51
+ },
52
+ },
53
+ "required": ["roots"],
54
+ },
55
+ )
56
+ def expand_polynomial_from_roots(roots: list[float], gain: float = 1.0) -> dict[str, Any]:
57
+ coefficients = (gain * np.poly(roots)).tolist()
58
+ return {
59
+ "status": "success",
60
+ "coefficients_descending": [float(c) for c in coefficients],
61
+ "degree": len(roots),
62
+ }
63
+
64
+
65
  @registry.register(
66
  name="bode_analysis",
67
+ description=(
68
+ "Compute the frequency response of G(s) = num(s)/den(s): magnitude in dB, phase in "
69
+ "degrees, resonant peak, DC gain, and the gain/phase margins with both crossover "
70
+ "frequencies."
71
+ ),
72
  parameters_schema={
73
  "type": "object",
74
  "properties": {
 
112
  "frequencies_sample": w_out[::10].tolist(),
113
  "magnitudes_db_sample": mag_db[::10].tolist(),
114
  "phases_deg_sample": phase_deg[::10].tolist(),
115
+ # Margins are included even though `stability_margins` is the tool that
116
+ # advertises them. Asked for a phase margin, the model was observed
117
+ # reaching for bode_analysis, getting back only sampled curves, and
118
+ # concluding the margin "cannot be determined" -- a correct reading of
119
+ # a sampled plot, and a useless answer. Computing them here makes that
120
+ # routing choice harmless rather than fatal.
121
+ "margins": stability_margins(numerator, denominator),
122
  }
123
 
124
 
controlai_agent/tools/synthesis.py CHANGED
@@ -43,6 +43,12 @@ def continuous_lqr(
43
  return {
44
  "P": P.tolist(),
45
  "K": K.tolist(),
 
 
 
 
 
 
46
  "closed_loop_poles": [[float(p.real), float(p.imag)] for p in poles],
47
  "is_stable": bool(np.all(np.real(poles) < 0)),
48
  "verification": v_report,
@@ -79,6 +85,7 @@ def discrete_lqr(
79
  return {
80
  "P": P.tolist(),
81
  "K": K.tolist(),
 
82
  "closed_loop_poles": [[float(p.real), float(p.imag)] for p in poles],
83
  "is_stable": bool(np.all(np.abs(poles) < 1.0)),
84
  "verification": v_report,
@@ -138,6 +145,7 @@ def place_state_feedback(
138
  v_report = verifier.verify_pole_placement(A_mat, B_mat, K, list(des))
139
  return {
140
  "K": K.tolist(),
 
141
  "closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_poles],
142
  "target_poles": [[float(p.real), float(p.imag)] for p in des],
143
  "verification": v_report,
 
43
  return {
44
  "P": P.tolist(),
45
  "K": K.tolist(),
46
+ # Returned so the answer never has to derive it. The model was
47
+ # observed writing A - BK as [[-6, 1], [-5, -6]] for a double
48
+ # integrator whose true closed loop is [[0, 1], [-6, -5]] -- correct
49
+ # gain, wrong write-up. Handing it the computed matrix removes the
50
+ # arithmetic from the answer entirely.
51
+ "closed_loop_A": (A - B @ K).tolist(),
52
  "closed_loop_poles": [[float(p.real), float(p.imag)] for p in poles],
53
  "is_stable": bool(np.all(np.real(poles) < 0)),
54
  "verification": v_report,
 
85
  return {
86
  "P": P.tolist(),
87
  "K": K.tolist(),
88
+ "closed_loop_A": (A - B @ K).tolist(),
89
  "closed_loop_poles": [[float(p.real), float(p.imag)] for p in poles],
90
  "is_stable": bool(np.all(np.abs(poles) < 1.0)),
91
  "verification": v_report,
 
145
  v_report = verifier.verify_pole_placement(A_mat, B_mat, K, list(des))
146
  return {
147
  "K": K.tolist(),
148
+ "closed_loop_A": (A - B @ K).tolist(),
149
  "closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_poles],
150
  "target_poles": [[float(p.real), float(p.imag)] for p in des],
151
  "verification": v_report,
requirements.txt CHANGED
@@ -1,32 +1,29 @@
1
- gradio>=5.9.0
2
- spaces>=0.30.0
 
 
 
 
 
 
 
3
  huggingface-hub>=0.23.0
4
- torch>=2.0.0
5
- transformers>=4.40.0
6
- accelerate>=0.28.0
7
- peft>=0.10.0
8
- jsonschema>=4.20.0
9
- scipy>=1.11.0
10
  numpy>=1.24.0
11
- matplotlib>=3.7.0
12
  control>=0.9.4
13
  cvxpy>=1.4.0
14
- rich>=13.7.0
 
 
 
15
  rank-bm25>=0.2.2
16
- # Pulls a prebuilt CPU wheel instead of compiling llama.cpp's C++ core from
17
- # source -- a source build routinely exceeds HF Spaces' build-job timeout
18
- # ("Job timeout" BUILD_ERROR, observed directly). This backend is CPU-only by
19
- # design (see the GGUF branch in orchestrator.py): llama.cpp's raw CUDA calls
20
- # aren't visible to ZeroGPU's torch-based interception, so a GPU wheel
21
- # wouldn't get real GPU time here anyway.
22
- --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
23
- llama-cpp-python==0.3.35
24
  pypdf>=3.17.0
25
  pymupdf>=1.23.0
26
- scikit-learn>=1.3.0
27
- requests>=2.31.0
28
- pyyaml>=6.0.0
29
- pydantic>=2.0.0
30
  fastapi>=0.110.0
31
  uvicorn>=0.28.0
32
  python-multipart>=0.0.9
 
 
1
+ # ControlAI runtime dependencies -- local, Apple Silicon.
2
+ #
3
+ # Inference is MLX only. The previous file also carried torch, transformers,
4
+ # accelerate, peft, llama-cpp-python, gradio and spaces to support a Hugging
5
+ # Face Spaces deployment and three alternative backends; none of that is used
6
+ # any more. transformers is still pulled in indirectly by mlx-lm for tokenizer
7
+ # loading, so it is not listed here.
8
+
9
+ mlx-lm>=0.31.0
10
  huggingface-hub>=0.23.0
11
+
12
+ # Deterministic numerics -- every number in an answer comes from these.
 
 
 
 
13
  numpy>=1.24.0
14
+ scipy>=1.11.0
15
  control>=0.9.4
16
  cvxpy>=1.4.0
17
+ matplotlib>=3.7.0
18
+ jsonschema>=4.20.0
19
+
20
+ # Retrieval over the local control-engineering library.
21
  rank-bm25>=0.2.2
 
 
 
 
 
 
 
 
22
  pypdf>=3.17.0
23
  pymupdf>=1.23.0
24
+
25
+ # Web console.
 
 
26
  fastapi>=0.110.0
27
  uvicorn>=0.28.0
28
  python-multipart>=0.0.9
29
+ pydantic>=2.0.0
scripts/eval_answer_quality.py CHANGED
@@ -31,7 +31,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
31
  if str(PROJECT_ROOT) not in sys.path:
32
  sys.path.insert(0, str(PROJECT_ROOT))
33
 
34
- from controlai_agent.orchestrator import ControlAIAgent
35
 
36
  PLACEHOLDER = "computational analysis has been completed"
37
 
@@ -67,7 +67,7 @@ DOUBLE_BS_PAT = re.compile(r"\\\\(?=[a-zA-Z|{}()])")
67
 
68
  def grade(question: str, result) -> tuple[list[str], dict]:
69
  """Return (list of problems, metrics) for one answer."""
70
- text = result.final_response or ""
71
  problems: list[str] = []
72
 
73
  if not text.strip():
@@ -86,8 +86,8 @@ def grade(question: str, result) -> tuple[list[str], dict]:
86
  if RAW_FILENAME_PAT.search(text):
87
  problems.append("RAW filename in citation")
88
 
89
- names = [t.tool_name for t in result.tool_traces]
90
- failed = [t.tool_name for t in result.tool_traces if t.result.get("status") == "error"]
91
  if failed:
92
  problems.append(f"TOOL ERROR: {', '.join(sorted(set(failed)))}")
93
  for n in set(names):
@@ -108,7 +108,7 @@ def main() -> int:
108
  cases = cases[: args.limit]
109
 
110
  print(f"Loading agent...\n")
111
- agent = ControlAIAgent()
112
 
113
  failures: list[tuple[str, str, list[str]]] = []
114
  for i, (domain, q) in enumerate(cases, 1):
 
31
  if str(PROJECT_ROOT) not in sys.path:
32
  sys.path.insert(0, str(PROJECT_ROOT))
33
 
34
+ from controlai_agent.agent import ControlAgent
35
 
36
  PLACEHOLDER = "computational analysis has been completed"
37
 
 
67
 
68
  def grade(question: str, result) -> tuple[list[str], dict]:
69
  """Return (list of problems, metrics) for one answer."""
70
+ text = result.answer or ""
71
  problems: list[str] = []
72
 
73
  if not text.strip():
 
86
  if RAW_FILENAME_PAT.search(text):
87
  problems.append("RAW filename in citation")
88
 
89
+ names = [t.name for t in result.traces]
90
+ failed = [t.name for t in result.traces if t.result.get("status") == "error"]
91
  if failed:
92
  problems.append(f"TOOL ERROR: {', '.join(sorted(set(failed)))}")
93
  for n in set(names):
 
108
  cases = cases[: args.limit]
109
 
110
  print(f"Loading agent...\n")
111
+ agent = ControlAgent()
112
 
113
  failures: list[tuple[str, str, list[str]]] = []
114
  for i, (domain, q) in enumerate(cases, 1):
scripts/run_agent_benchmark.py CHANGED
@@ -13,7 +13,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
13
  if str(PROJECT_ROOT) not in sys.path:
14
  sys.path.insert(0, str(PROJECT_ROOT))
15
 
16
- from controlai_agent.orchestrator import ControlAIAgent
 
17
 
18
 
19
  def main() -> int:
@@ -59,7 +60,7 @@ def main() -> int:
59
  print(f"Total benchmark items: {len(items)}")
60
 
61
  print(f"Initializing ControlAI Agent ({args.model})...")
62
- agent = ControlAIAgent(model_path=args.model, adapter_path=args.adapter_path)
63
  print("Agent ready.")
64
 
65
  responses = []
@@ -72,27 +73,27 @@ def main() -> int:
72
  print(f"[{idx}/{len(items)}] Evaluating {item_id}...", end=" ", flush=True)
73
 
74
  item_start = time.time()
75
- result = agent.run(prompt, max_tokens_per_step=args.max_tokens)
76
  elapsed = time.time() - item_start
77
 
78
- tool_names = [t.tool_name for t in result.tool_traces]
79
- print(f"done in {elapsed:.2f}s | Steps: {result.total_steps} | Tools: {tool_names}")
80
 
81
  record = {
82
  "benchmark_id": item_id,
83
  "id": item_id,
84
  "family": item.get("family", ""),
85
  "prompt": prompt,
86
- "response": result.final_response,
87
  "tool_calls": [
88
  {
89
- "name": t.tool_name,
90
  "arguments": t.arguments,
91
  "result": t.result,
92
  }
93
- for t in result.tool_traces
94
  ],
95
- "total_steps": result.total_steps,
96
  "finish_reason": "stop",
97
  }
98
  responses.append(record)
 
13
  if str(PROJECT_ROOT) not in sys.path:
14
  sys.path.insert(0, str(PROJECT_ROOT))
15
 
16
+ from controlai_agent.agent import ControlAgent
17
+ from controlai_agent.engine import LocalEngine
18
 
19
 
20
  def main() -> int:
 
60
  print(f"Total benchmark items: {len(items)}")
61
 
62
  print(f"Initializing ControlAI Agent ({args.model})...")
63
+ agent = ControlAgent(engine=LocalEngine(model_id=args.model, adapter_path=args.adapter_path))
64
  print("Agent ready.")
65
 
66
  responses = []
 
73
  print(f"[{idx}/{len(items)}] Evaluating {item_id}...", end=" ", flush=True)
74
 
75
  item_start = time.time()
76
+ result = agent.run(prompt, max_tokens=args.max_tokens)
77
  elapsed = time.time() - item_start
78
 
79
+ tool_names = [t.name for t in result.traces]
80
+ print(f"done in {elapsed:.2f}s | Steps: {len(result.traces)} | Tools: {tool_names}")
81
 
82
  record = {
83
  "benchmark_id": item_id,
84
  "id": item_id,
85
  "family": item.get("family", ""),
86
  "prompt": prompt,
87
+ "response": result.answer,
88
  "tool_calls": [
89
  {
90
+ "name": t.name,
91
  "arguments": t.arguments,
92
  "result": t.result,
93
  }
94
+ for t in result.traces
95
  ],
96
+ "total_steps": len(result.traces),
97
  "finish_reason": "stop",
98
  }
99
  responses.append(record)
tests/test_agent_core.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the model-independent parts of the agent: tool-call parsing,
2
+ streamed tool-call suppression, and history truncation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
11
+ if str(PROJECT_ROOT) not in sys.path:
12
+ sys.path.insert(0, str(PROJECT_ROOT))
13
+
14
+ from controlai_agent.agent import _StreamGate
15
+ from controlai_agent.toolcall import degenerate_reason, parse
16
+
17
+
18
+ class TestToolCallParsing(unittest.TestCase):
19
+ def test_plain_call(self):
20
+ calls, prose = parse(
21
+ '<tool_call>\n{"name": "continuous_lqr", "arguments": {"A": [[0, 1], [-2, -3]]}}\n</tool_call>'
22
+ )
23
+ self.assertEqual(len(calls), 1)
24
+ self.assertEqual(calls[0].name, "continuous_lqr")
25
+ self.assertEqual(calls[0].arguments["A"], [[0, 1], [-2, -3]])
26
+ self.assertEqual(prose, "")
27
+
28
+ def test_narration_before_call_is_kept_separate(self):
29
+ calls, prose = parse(
30
+ 'Let me compute that.\n<tool_call>\n{"name": "eigen_analysis", "arguments": {}}\n</tool_call>'
31
+ )
32
+ self.assertEqual([c.name for c in calls], ["eigen_analysis"])
33
+ self.assertEqual(prose, "Let me compute that.")
34
+
35
+ def test_truncated_call_is_recovered(self):
36
+ """Hitting the token limit mid-call should not discard the call."""
37
+ calls, _ = parse('<tool_call>\n{"name": "bode_analysis", "arguments": {"numerator": [10]')
38
+ self.assertEqual(len(calls), 1)
39
+ self.assertEqual(calls[0].arguments["numerator"], [10])
40
+
41
+ def test_empty_tool_call_yields_no_call(self):
42
+ """The failure mode of the old fine-tuned adapter: an empty call plus
43
+ real prose. The prose must survive and no tool must run."""
44
+ calls, prose = parse("<tool_call>\n\n</tool_call>\n\nThe phase margin is the answer.")
45
+ self.assertEqual(calls, [])
46
+ self.assertEqual(prose, "The phase margin is the answer.")
47
+
48
+ def test_math_is_not_mangled(self):
49
+ _, prose = parse(r"Gain margin is $6$ dB with $\zeta = 0.5$.")
50
+ self.assertEqual(prose, r"Gain margin is $6$ dB with $\zeta = 0.5$.")
51
+
52
+ def test_multiple_calls(self):
53
+ calls, _ = parse(
54
+ '<tool_call>\n{"name": "a", "arguments": {}}\n</tool_call>'
55
+ '<tool_call>\n{"name": "b", "arguments": {}}\n</tool_call>'
56
+ )
57
+ self.assertEqual([c.name for c in calls], ["a", "b"])
58
+
59
+ def test_thinking_block_stripped(self):
60
+ _, prose = parse("<think>weighing options</think>\n\nThe answer is 3 dB.")
61
+ self.assertEqual(prose, "The answer is 3 dB.")
62
+
63
+
64
+ class TestDegenerateGuard(unittest.TestCase):
65
+ def test_repetition_loop_detected(self):
66
+ self.assertIsNotNone(degenerate_reason([[0.5] * 40]))
67
+
68
+ def test_runaway_length_detected(self):
69
+ self.assertIsNotNone(degenerate_reason(list(range(500))))
70
+
71
+ def test_ordinary_matrices_pass(self):
72
+ for value in ([[0, 1], [-2, -3]], [[1.0]], [1, 6, 5, 0], [[0], [1]]):
73
+ self.assertIsNone(degenerate_reason(value), value)
74
+
75
+ def test_identity_matrix_passes(self):
76
+ """A legitimate matrix full of repeated values must not be rejected."""
77
+ self.assertIsNone(degenerate_reason([[1 if i == j else 0 for j in range(8)] for i in range(8)]))
78
+
79
+
80
+ class TestStreamGate(unittest.TestCase):
81
+ def test_prose_passes_through(self):
82
+ gate = _StreamGate()
83
+ self.assertEqual(gate.feed("The gain margin "), "The gain margin ")
84
+ self.assertEqual(gate.feed("is 6 dB."), "is 6 dB.")
85
+
86
+ def test_tool_call_never_leaks(self):
87
+ gate = _StreamGate()
88
+ emitted = "".join(gate.feed(part) for part in ("Computing.", "<tool", "_call>", '{"name"', "}"))
89
+ self.assertEqual(emitted, "Computing.")
90
+ self.assertTrue(gate.suppressed)
91
+ self.assertEqual(gate.flush(), "")
92
+
93
+ def test_marker_split_across_chunks_is_held_back(self):
94
+ gate = _StreamGate()
95
+ self.assertEqual(gate.feed("done<too"), "done")
96
+ self.assertEqual(gate.feed("l_call>x"), "")
97
+
98
+ def test_partial_lookalike_is_released(self):
99
+ """`<t` that turns out to be something else must not be swallowed."""
100
+ gate = _StreamGate()
101
+ gate.feed("value <t")
102
+ self.assertEqual(gate.feed("hreshold>"), "<threshold>")
103
+
104
+
105
+ class TestHistoryTruncation(unittest.TestCase):
106
+ def test_oldest_turns_dropped_first(self):
107
+ from controlai_agent import agent as agent_module
108
+
109
+ class FakeEngine:
110
+ def count_tokens(self, text):
111
+ return len(text.split())
112
+
113
+ holder = object.__new__(agent_module.ControlAgent)
114
+ holder.engine = FakeEngine()
115
+ history = [
116
+ {"role": "user", "content": "old " * 100},
117
+ {"role": "assistant", "content": "reply " * 100},
118
+ {"role": "user", "content": "recent question"},
119
+ ]
120
+ kept = holder._truncate(history)
121
+ self.assertEqual(kept[-1]["content"], "recent question")
122
+ self.assertLessEqual(len(kept), 3)
123
+
124
+ def test_blank_and_tool_turns_dropped(self):
125
+ from controlai_agent import agent as agent_module
126
+
127
+ class FakeEngine:
128
+ def count_tokens(self, text):
129
+ return len(text.split())
130
+
131
+ holder = object.__new__(agent_module.ControlAgent)
132
+ holder.engine = FakeEngine()
133
+ kept = holder._truncate(
134
+ [{"role": "tool", "content": "x"}, {"role": "user", "content": " "}, {"role": "user", "content": "hi"}]
135
+ )
136
+ self.assertEqual(kept, [{"role": "user", "content": "hi"}])
137
+
138
+
139
+ if __name__ == "__main__":
140
+ unittest.main()