Spaces:
Running on Zero
refactor: Collapse four inference backends into one MLX path
Browse filesThe 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 +182 -0
- README.md +47 -40
- app.py +183 -259
- cli.py +84 -55
- controlai_agent/agent.py +418 -0
- controlai_agent/engine.py +393 -0
- controlai_agent/orchestrator.py +0 -1327
- controlai_agent/prompts.py +66 -41
- controlai_agent/toolcall.py +119 -0
- controlai_agent/tools.py +0 -427
- controlai_agent/tools/frequency.py +57 -1
- controlai_agent/tools/synthesis.py +8 -0
- requirements.txt +19 -22
- scripts/eval_answer_quality.py +5 -5
- scripts/run_agent_benchmark.py +10 -9
- tests/test_agent_core.py +140 -0
|
@@ -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.
|
|
@@ -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 |
[](https://huggingface.co/spaces/atakankahya/ControlAI-Agent)
|
|
@@ -19,23 +10,29 @@ pinned: false
|
|
| 19 |
|
| 20 |
---
|
| 21 |
|
| 22 |
-
## Quickstart
|
| 23 |
|
| 24 |
-
ControlAI runs
|
|
|
|
|
|
|
| 25 |
|
| 26 |
```bash
|
| 27 |
-
# 1. Clone the repository
|
| 28 |
git clone https://github.com/atakankahya/controlai-agent.git
|
| 29 |
cd controlai-agent
|
|
|
|
| 30 |
|
| 31 |
-
#
|
| 32 |
-
./run.sh
|
|
|
|
|
|
|
| 33 |
```
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]) -->
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
subgraph
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
Tools[Deterministic
|
| 69 |
end
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
Tools -->
|
| 77 |
-
Tools -->
|
| 78 |
-
Tools -->
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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 |
[](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 |
|
|
@@ -1,10 +1,21 @@
|
|
| 1 |
-
"""ControlAI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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.
|
| 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 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
#
|
| 42 |
-
|
| 43 |
-
#
|
| 44 |
-
#
|
| 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
|
| 62 |
-
|
| 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 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
| 75 |
yield
|
| 76 |
|
| 77 |
|
| 78 |
-
app = FastAPI(title="ControlAI", version="
|
| 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 |
-
|
| 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 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
|
| 161 |
|
| 162 |
@app.get("/api/status")
|
| 163 |
-
async def
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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":
|
| 179 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
@app.get("/api/documents")
|
| 183 |
async def list_documents() -> dict[str, Any]:
|
| 184 |
categories: dict[str, list[str]] = {}
|
| 185 |
-
|
| 186 |
-
|
|
|
|
| 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[
|
| 192 |
return {"categories": categories}
|
| 193 |
|
| 194 |
|
| 195 |
@app.post("/api/upload")
|
| 196 |
async def upload_document(file: UploadFile = File(...)) -> dict[str, Any]:
|
| 197 |
-
|
| 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 |
-
|
| 206 |
-
with
|
| 207 |
-
shutil.copyfileobj(file.file,
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
@app.post("/api/chat/stream")
|
| 238 |
-
async def
|
| 239 |
-
|
|
|
|
| 240 |
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
| 241 |
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 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 |
-
|
| 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
|
| 300 |
-
|
|
|
|
| 301 |
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
| 302 |
|
| 303 |
-
|
| 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 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
if p_path.exists():
|
| 324 |
-
plots.append(f"/plots/{p_path.name}")
|
| 325 |
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
tool_traces=traces,
|
| 329 |
-
plots=plots,
|
| 330 |
-
elapsed_seconds=round(elapsed, 2),
|
| 331 |
-
)
|
| 332 |
except Exception as exc:
|
| 333 |
-
|
| 334 |
-
print(f"Chat execution error: {exc}")
|
| 335 |
return ChatResponse(
|
| 336 |
-
response=f"
|
| 337 |
-
|
| 338 |
-
plots=[],
|
| 339 |
-
elapsed_seconds=round(elapsed, 2),
|
| 340 |
)
|
| 341 |
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 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
|
| 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 |
-
|
| 375 |
-
|
|
|
|
|
|
|
| 376 |
try:
|
| 377 |
webbrowser.open("http://127.0.0.1:8000")
|
| 378 |
except Exception:
|
| 379 |
pass
|
| 380 |
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
|
|
|
| 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__":
|
|
@@ -1,85 +1,114 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Interactive
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def main() -> int:
|
| 18 |
-
parser = argparse.ArgumentParser(description="ControlAI
|
| 19 |
-
parser.add_argument(
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
default="mlx-community/Qwen3-4B-Instruct-2507-4bit",
|
| 23 |
-
help="Base model path or HuggingFace repo",
|
| 24 |
-
)
|
| 25 |
parser.add_argument(
|
| 26 |
-
"--
|
| 27 |
-
|
| 28 |
default=None,
|
| 29 |
-
help="
|
| 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("
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
if args.prompt:
|
| 49 |
-
|
| 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("
|
| 62 |
-
|
| 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 |
-
|
| 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("\
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__":
|
|
@@ -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
|
|
@@ -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 |
+
)
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,42 +1,67 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 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."""
|
|
@@ -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
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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,
|
|
@@ -1,32 +1,29 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
huggingface-hub>=0.23.0
|
| 4 |
-
|
| 5 |
-
|
| 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 |
-
|
| 12 |
control>=0.9.4
|
| 13 |
cvxpy>=1.4.0
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 27 |
-
|
| 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
|
|
@@ -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.
|
| 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.
|
| 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.
|
| 90 |
-
failed = [t.
|
| 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 =
|
| 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):
|
|
@@ -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.
|
|
|
|
| 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 =
|
| 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,
|
| 76 |
elapsed = time.time() - item_start
|
| 77 |
|
| 78 |
-
tool_names = [t.
|
| 79 |
-
print(f"done in {elapsed:.2f}s | Steps: {result.
|
| 80 |
|
| 81 |
record = {
|
| 82 |
"benchmark_id": item_id,
|
| 83 |
"id": item_id,
|
| 84 |
"family": item.get("family", ""),
|
| 85 |
"prompt": prompt,
|
| 86 |
-
"response": result.
|
| 87 |
"tool_calls": [
|
| 88 |
{
|
| 89 |
-
"name": t.
|
| 90 |
"arguments": t.arguments,
|
| 91 |
"result": t.result,
|
| 92 |
}
|
| 93 |
-
for t in result.
|
| 94 |
],
|
| 95 |
-
"total_steps": result.
|
| 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)
|
|
@@ -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()
|