fugacio-copilot 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. fugacio_copilot-0.0.1/.gitignore +32 -0
  2. fugacio_copilot-0.0.1/PKG-INFO +80 -0
  3. fugacio_copilot-0.0.1/README.md +58 -0
  4. fugacio_copilot-0.0.1/pyproject.toml +43 -0
  5. fugacio_copilot-0.0.1/src/fugacio/copilot/__init__.py +86 -0
  6. fugacio_copilot-0.0.1/src/fugacio/copilot/agent.py +248 -0
  7. fugacio_copilot-0.0.1/src/fugacio/copilot/dynamics_tools.py +385 -0
  8. fugacio_copilot-0.0.1/src/fugacio/copilot/integration_tools.py +311 -0
  9. fugacio_copilot-0.0.1/src/fugacio/copilot/llm/__init__.py +32 -0
  10. fugacio_copilot-0.0.1/src/fugacio/copilot/llm/anthropic.py +104 -0
  11. fugacio_copilot-0.0.1/src/fugacio/copilot/llm/base.py +141 -0
  12. fugacio_copilot-0.0.1/src/fugacio/copilot/llm/mock.py +52 -0
  13. fugacio_copilot-0.0.1/src/fugacio/copilot/llm/openai.py +95 -0
  14. fugacio_copilot-0.0.1/src/fugacio/copilot/mpc_tools.py +458 -0
  15. fugacio_copilot-0.0.1/src/fugacio/copilot/py.typed +0 -0
  16. fugacio_copilot-0.0.1/src/fugacio/copilot/report.py +268 -0
  17. fugacio_copilot-0.0.1/src/fugacio/copilot/saft_tools.py +252 -0
  18. fugacio_copilot-0.0.1/src/fugacio/copilot/tools.py +2092 -0
  19. fugacio_copilot-0.0.1/tests/test_agent.py +57 -0
  20. fugacio_copilot-0.0.1/tests/test_copilot.py +10 -0
  21. fugacio_copilot-0.0.1/tests/test_design_tools.py +85 -0
  22. fugacio_copilot-0.0.1/tests/test_dynamics_tools.py +89 -0
  23. fugacio_copilot-0.0.1/tests/test_integration_tools.py +83 -0
  24. fugacio_copilot-0.0.1/tests/test_llm_agent.py +91 -0
  25. fugacio_copilot-0.0.1/tests/test_mpc_tools.py +120 -0
  26. fugacio_copilot-0.0.1/tests/test_nonideal_tools.py +164 -0
  27. fugacio_copilot-0.0.1/tests/test_property_tools.py +73 -0
  28. fugacio_copilot-0.0.1/tests/test_reaction_tools.py +162 -0
  29. fugacio_copilot-0.0.1/tests/test_report.py +66 -0
  30. fugacio_copilot-0.0.1/tests/test_saft_tools.py +82 -0
  31. fugacio_copilot-0.0.1/tests/test_steam_tools.py +90 -0
  32. fugacio_copilot-0.0.1/tests/test_tools.py +197 -0
@@ -0,0 +1,32 @@
1
+ # Byte-compiled / build artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Tooling caches
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .import_linter_cache/
18
+ .jax_cache/
19
+ .coverage
20
+ coverage.xml
21
+ htmlcov/
22
+
23
+ # Docs build
24
+ site/
25
+ # Material social-cards plugin cache (downloaded fonts + generated card layers)
26
+ .cache/
27
+
28
+ # OS / editor
29
+ .DS_Store
30
+ *.swp
31
+ .idea/
32
+ .vscode/
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: fugacio-copilot
3
+ Version: 0.0.1
4
+ Summary: Chemical-engineering design copilot/agent for the Fugacio stack.
5
+ Author-email: Owen Carey <37121709+owenthcarey@users.noreply.github.com>
6
+ License-Expression: Apache-2.0
7
+ Keywords: agent,chemical-engineering,copilot,llm,process-design
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: fugacio-sim
14
+ Provides-Extra: anthropic
15
+ Requires-Dist: anthropic>=0.34; extra == 'anthropic'
16
+ Provides-Extra: llm
17
+ Requires-Dist: anthropic>=0.34; extra == 'llm'
18
+ Requires-Dist: openai>=1.40; extra == 'llm'
19
+ Provides-Extra: openai
20
+ Requires-Dist: openai>=1.40; extra == 'openai'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # fugacio-copilot
24
+
25
+ Chemical-engineering design copilot/agent for the
26
+ [Fugacio](https://github.com/fugacio/fugacio) stack. It sits on top of
27
+ `fugacio.sim` and turns natural-language design goals into engineering
28
+ calculations: flowsheets, equipment sizing, and (eventually) techno-economic /
29
+ life-cycle analysis.
30
+
31
+ The bridge between a language model and the differentiable engine is a **tool
32
+ registry**, deterministic, JSON-in/JSON-out functions exposed with the same
33
+ function-calling schemas OpenAI/Anthropic expect:
34
+
35
+ - **Properties & equilibrium**: `list_components`, `component_properties`,
36
+ `saturation_pressure`, `bubble_pressure`, `flash_drum`.
37
+ - **Molecular PC-SAFT**: `saft_flash`, `saft_density`, `saft_saturation_pressure`,
38
+ `saft_bubble_pressure`, and `saft_residual_enthalpy`, the molecular EOS preferred
39
+ for associating fluids (water, alcohols).
40
+ - **Unit operations**: `heat_exchanger`, `compressor` (and turbine), `pump`,
41
+ `valve`, each closing a rigorous energy balance.
42
+ - **Distillation**: `shortcut_distillation` (Fenske-Underwood-Gilliland) and
43
+ `rigorous_distillation` (multistage column with duties).
44
+ - **Gradient-based optimization**: `optimize_flash_temperature` and
45
+ `optimize_column_reflux` solve for the operating variable that hits a target by
46
+ differentiating straight through the equilibrium flash and the column.
47
+
48
+ A model-agnostic **agent loop** (`run_agent`) drives plan→act→answer; the planner
49
+ is injected, so the loop is fully testable with a scripted planner while a real
50
+ LLM drops in behind the optional `llm` extra.
51
+
52
+ ```python
53
+ from fugacio.copilot import call_tool, run_agent, tool_schemas
54
+
55
+ # Call an engine-backed tool directly (JSON in / JSON out):
56
+ call_tool("saturation_pressure", {"component": "propane", "temperature": 300.0})
57
+
58
+ # Or drive the agent loop with your own planner (an LLM in production):
59
+ def planner(goal, tools, transcript):
60
+ if not transcript:
61
+ return {"tool": "flash_drum", "arguments": {
62
+ "components": ["methane", "propane", "n-pentane"],
63
+ "z": [0.5, 0.3, 0.2], "flow": 100.0,
64
+ "temperature": 320.0, "pressure": 20e5,
65
+ }}
66
+ vf = transcript[-1]["result"]["vapor_fraction"]
67
+ return {"final_answer": f"vapor fraction = {vf:.3f}"}
68
+
69
+ run_agent("Flash this feed", planner).answer # 'vapor fraction = 0.747'
70
+ ```
71
+
72
+ `tool_schemas()` returns the schemas to hand an LLM. LLM-backed planning lives
73
+ behind the optional `llm` extra:
74
+
75
+ ```bash
76
+ pip install "fugacio-copilot[llm]"
77
+ ```
78
+
79
+ Part of the `fugacio` namespace; installs independently:
80
+ `pip install fugacio-copilot`.
@@ -0,0 +1,58 @@
1
+ # fugacio-copilot
2
+
3
+ Chemical-engineering design copilot/agent for the
4
+ [Fugacio](https://github.com/fugacio/fugacio) stack. It sits on top of
5
+ `fugacio.sim` and turns natural-language design goals into engineering
6
+ calculations: flowsheets, equipment sizing, and (eventually) techno-economic /
7
+ life-cycle analysis.
8
+
9
+ The bridge between a language model and the differentiable engine is a **tool
10
+ registry**, deterministic, JSON-in/JSON-out functions exposed with the same
11
+ function-calling schemas OpenAI/Anthropic expect:
12
+
13
+ - **Properties & equilibrium**: `list_components`, `component_properties`,
14
+ `saturation_pressure`, `bubble_pressure`, `flash_drum`.
15
+ - **Molecular PC-SAFT**: `saft_flash`, `saft_density`, `saft_saturation_pressure`,
16
+ `saft_bubble_pressure`, and `saft_residual_enthalpy`, the molecular EOS preferred
17
+ for associating fluids (water, alcohols).
18
+ - **Unit operations**: `heat_exchanger`, `compressor` (and turbine), `pump`,
19
+ `valve`, each closing a rigorous energy balance.
20
+ - **Distillation**: `shortcut_distillation` (Fenske-Underwood-Gilliland) and
21
+ `rigorous_distillation` (multistage column with duties).
22
+ - **Gradient-based optimization**: `optimize_flash_temperature` and
23
+ `optimize_column_reflux` solve for the operating variable that hits a target by
24
+ differentiating straight through the equilibrium flash and the column.
25
+
26
+ A model-agnostic **agent loop** (`run_agent`) drives plan→act→answer; the planner
27
+ is injected, so the loop is fully testable with a scripted planner while a real
28
+ LLM drops in behind the optional `llm` extra.
29
+
30
+ ```python
31
+ from fugacio.copilot import call_tool, run_agent, tool_schemas
32
+
33
+ # Call an engine-backed tool directly (JSON in / JSON out):
34
+ call_tool("saturation_pressure", {"component": "propane", "temperature": 300.0})
35
+
36
+ # Or drive the agent loop with your own planner (an LLM in production):
37
+ def planner(goal, tools, transcript):
38
+ if not transcript:
39
+ return {"tool": "flash_drum", "arguments": {
40
+ "components": ["methane", "propane", "n-pentane"],
41
+ "z": [0.5, 0.3, 0.2], "flow": 100.0,
42
+ "temperature": 320.0, "pressure": 20e5,
43
+ }}
44
+ vf = transcript[-1]["result"]["vapor_fraction"]
45
+ return {"final_answer": f"vapor fraction = {vf:.3f}"}
46
+
47
+ run_agent("Flash this feed", planner).answer # 'vapor fraction = 0.747'
48
+ ```
49
+
50
+ `tool_schemas()` returns the schemas to hand an LLM. LLM-backed planning lives
51
+ behind the optional `llm` extra:
52
+
53
+ ```bash
54
+ pip install "fugacio-copilot[llm]"
55
+ ```
56
+
57
+ Part of the `fugacio` namespace; installs independently:
58
+ `pip install fugacio-copilot`.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "fugacio-copilot"
3
+ version = "0.0.1"
4
+ description = "Chemical-engineering design copilot/agent for the Fugacio stack."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ authors = [
9
+ { name = "Owen Carey", email = "37121709+owenthcarey@users.noreply.github.com" },
10
+ ]
11
+ keywords = [
12
+ "llm",
13
+ "agent",
14
+ "chemical-engineering",
15
+ "process-design",
16
+ "copilot",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 2 - Pre-Alpha",
20
+ "Intended Audience :: Science/Research",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering :: Chemistry",
23
+ ]
24
+ dependencies = [
25
+ "fugacio-sim",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ # LLM provider SDKs. The copilot core works without these (e.g. via the mock
30
+ # provider); install the relevant extra to talk to a real model.
31
+ openai = ["openai>=1.40"]
32
+ anthropic = ["anthropic>=0.34"]
33
+ llm = ["openai>=1.40", "anthropic>=0.34"]
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/fugacio"]
41
+
42
+ [tool.uv.sources]
43
+ fugacio-sim = { workspace = true }
@@ -0,0 +1,86 @@
1
+ """Chemical-engineering design copilot for Fugacio (depends on ``fugacio.sim``).
2
+
3
+ The copilot exposes the differentiable engine to a language-model design agent
4
+ through:
5
+
6
+ * a **tool registry** (`default_registry`, `tool_schemas`,
7
+ `call_tool`) of deterministic, JSON-in/JSON-out engineering calculations
8
+ spanning properties, unit operations, distillation, reactors, optimization,
9
+ design specs, and economics;
10
+ * a vendor-neutral **LLM provider layer** (`OpenAIProvider`,
11
+ `AnthropicProvider`, and the test `MockProvider`) behind the
12
+ optional ``llm`` extra;
13
+ * a model-agnostic **agent loop** (`run_agent`) plus a real multi-turn
14
+ function-calling loop (`run_llm_agent`), with planner adapters
15
+ (`llm_planner`, `heuristic_planner`);
16
+ * human-readable **reports** (`summarize_bubble_point`, and the richer
17
+ markdown summaries in `fugacio.copilot.report`).
18
+ """
19
+
20
+ from fugacio.copilot.agent import (
21
+ DEFAULT_SYSTEM_PROMPT,
22
+ AgentResult,
23
+ Planner,
24
+ heuristic_planner,
25
+ llm_planner,
26
+ run_agent,
27
+ run_llm_agent,
28
+ )
29
+ from fugacio.copilot.llm import (
30
+ AnthropicProvider,
31
+ ChatResponse,
32
+ LLMProvider,
33
+ Message,
34
+ MockProvider,
35
+ OpenAIProvider,
36
+ ToolCall,
37
+ )
38
+ from fugacio.copilot.report import (
39
+ stream_table,
40
+ summarize_bubble_point,
41
+ summarize_economics,
42
+ summarize_heat_integration,
43
+ summarize_lqr_design,
44
+ summarize_mpc_simulation,
45
+ summarize_optimization,
46
+ summarize_pid_tuning,
47
+ summarize_transcript,
48
+ )
49
+ from fugacio.copilot.tools import (
50
+ ToolSpec,
51
+ call_tool,
52
+ default_registry,
53
+ tool_schemas,
54
+ )
55
+
56
+ __all__ = [
57
+ "DEFAULT_SYSTEM_PROMPT",
58
+ "AgentResult",
59
+ "AnthropicProvider",
60
+ "ChatResponse",
61
+ "LLMProvider",
62
+ "Message",
63
+ "MockProvider",
64
+ "OpenAIProvider",
65
+ "Planner",
66
+ "ToolCall",
67
+ "ToolSpec",
68
+ "call_tool",
69
+ "default_registry",
70
+ "heuristic_planner",
71
+ "llm_planner",
72
+ "run_agent",
73
+ "run_llm_agent",
74
+ "stream_table",
75
+ "summarize_bubble_point",
76
+ "summarize_economics",
77
+ "summarize_heat_integration",
78
+ "summarize_lqr_design",
79
+ "summarize_mpc_simulation",
80
+ "summarize_optimization",
81
+ "summarize_pid_tuning",
82
+ "summarize_transcript",
83
+ "tool_schemas",
84
+ ]
85
+
86
+ __version__ = "0.0.1"
@@ -0,0 +1,248 @@
1
+ """Tool-calling agent loops for the Fugacio design copilot.
2
+
3
+ Two complementary loops share one tool registry:
4
+
5
+ * `run_agent`: the original *model-agnostic* loop. A **planner** callable
6
+ ``(goal, tool_schemas, transcript) -> decision`` decides the next tool call or
7
+ the final answer, where ``decision`` is ``{"tool": name, "arguments": {...}}``
8
+ or ``{"final_answer": text}``. This keeps the control flow fully testable with
9
+ a scripted planner and lets any decision policy drop in.
10
+
11
+ * `run_llm_agent`: a real multi-turn function-calling loop over an
12
+ `LLMProvider` (OpenAI, Anthropic, or the test
13
+ `MockProvider`). It maintains the full message
14
+ history with tool-call ids, executes every requested tool (validating
15
+ arguments and capturing errors so the model can self-correct), feeds the JSON
16
+ results back, and returns when the model answers in plain text.
17
+
18
+ `llm_planner` bridges the two: it turns a provider into a planner for the
19
+ simple loop. A deterministic `heuristic_planner` is provided for tests and
20
+ offline use.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from collections.abc import Callable, Sequence
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ from fugacio.copilot.llm.base import ChatResponse, LLMProvider, Message, ToolCall
31
+ from fugacio.copilot.tools import ToolSpec, call_tool, default_registry, tool_schemas
32
+
33
+ JsonDict = dict[str, Any]
34
+ Planner = Callable[[str, list[JsonDict], list[JsonDict]], JsonDict]
35
+
36
+ #: Default system prompt grounding the model as a chemical-engineering copilot.
37
+ DEFAULT_SYSTEM_PROMPT = (
38
+ "You are Fugacio, an expert chemical-process design copilot. You answer "
39
+ "engineering questions by calling the provided tools, which run a rigorous, "
40
+ "differentiable thermodynamics and flowsheet engine. Prefer computing with "
41
+ "tools over estimating from memory. Use SI units (kelvin, pascal, mol/s, "
42
+ "watts, dollars). When you have enough information, give a concise, "
43
+ "quantitative final answer that cites the numbers the tools returned."
44
+ )
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class AgentResult:
49
+ """Outcome of an agent run.
50
+
51
+ Attributes:
52
+ answer: The final natural-language (or structured) answer.
53
+ transcript: Ordered tool calls with their arguments and results.
54
+ stop_reason: Why the loop ended (``"answer"`` or ``"budget"``).
55
+ steps: Number of planner / model turns taken.
56
+ """
57
+
58
+ answer: str
59
+ transcript: list[JsonDict] = field(default_factory=list)
60
+ stop_reason: str = "answer"
61
+ steps: int = 0
62
+
63
+
64
+ def run_agent(
65
+ goal: str,
66
+ planner: Planner,
67
+ *,
68
+ registry: dict[str, ToolSpec] | None = None,
69
+ max_steps: int = 6,
70
+ ) -> AgentResult:
71
+ """Run the plan/act loop until the planner returns a final answer.
72
+
73
+ Args:
74
+ goal: The natural-language design goal.
75
+ planner: Decision function (see module docstring); inject an LLM via
76
+ `llm_planner`, or pass a scripted/heuristic planner.
77
+ registry: Tool registry to expose (defaults to `default_registry`).
78
+ max_steps: Maximum number of tool calls before giving up.
79
+
80
+ Returns:
81
+ An `AgentResult` with the final answer and the full transcript.
82
+ """
83
+ registry = default_registry() if registry is None else registry
84
+ schemas = tool_schemas(registry)
85
+ transcript: list[JsonDict] = []
86
+ for step in range(max_steps):
87
+ decision = planner(goal, schemas, transcript)
88
+ if "final_answer" in decision:
89
+ return AgentResult(
90
+ answer=str(decision["final_answer"]),
91
+ transcript=transcript,
92
+ stop_reason="answer",
93
+ steps=step + 1,
94
+ )
95
+ name = decision["tool"]
96
+ arguments = decision.get("arguments", {})
97
+ result = call_tool(name, arguments, registry)
98
+ transcript.append({"tool": name, "arguments": arguments, "result": result})
99
+ return AgentResult(
100
+ answer="step budget exhausted before a final answer",
101
+ transcript=transcript,
102
+ stop_reason="budget",
103
+ steps=max_steps,
104
+ )
105
+
106
+
107
+ def run_llm_agent(
108
+ goal: str,
109
+ provider: LLMProvider,
110
+ *,
111
+ registry: dict[str, ToolSpec] | None = None,
112
+ system: str = DEFAULT_SYSTEM_PROMPT,
113
+ max_steps: int = 8,
114
+ temperature: float = 0.0,
115
+ max_tokens: int = 1024,
116
+ ) -> AgentResult:
117
+ """Drive a real function-calling LLM through the tool registry to an answer.
118
+
119
+ Maintains the full chat history with tool-call ids, executes each requested
120
+ tool (validating arguments and turning errors into a JSON error result the
121
+ model can recover from), and loops until the model replies with plain text or
122
+ the step budget is exhausted.
123
+
124
+ Args:
125
+ goal: The user's natural-language request.
126
+ provider: Any `LLMProvider`.
127
+ registry: Tool registry (defaults to `default_registry`).
128
+ system: System prompt; defaults to `DEFAULT_SYSTEM_PROMPT`.
129
+ max_steps: Maximum model turns.
130
+ temperature: Sampling temperature.
131
+ max_tokens: Per-turn token cap.
132
+
133
+ Returns:
134
+ An `AgentResult`.
135
+ """
136
+ registry = default_registry() if registry is None else registry
137
+ schemas = tool_schemas(registry)
138
+ messages: list[Message] = [Message.system(system), Message.user(goal)]
139
+ transcript: list[JsonDict] = []
140
+
141
+ for step in range(max_steps):
142
+ reply: ChatResponse = provider.chat(
143
+ messages, tools=schemas, temperature=temperature, max_tokens=max_tokens
144
+ )
145
+ if not reply.has_tool_calls:
146
+ return AgentResult(
147
+ answer=reply.content,
148
+ transcript=transcript,
149
+ stop_reason="answer",
150
+ steps=step + 1,
151
+ )
152
+ messages.append(Message.assistant(reply.content, reply.tool_calls))
153
+ for call in reply.tool_calls:
154
+ result = _safe_call(call.name, call.arguments, registry)
155
+ transcript.append({"tool": call.name, "arguments": call.arguments, "result": result})
156
+ messages.append(Message.tool(json.dumps(result), call.id, call.name))
157
+
158
+ return AgentResult(
159
+ answer="step budget exhausted before a final answer",
160
+ transcript=transcript,
161
+ stop_reason="budget",
162
+ steps=max_steps,
163
+ )
164
+
165
+
166
+ def _safe_call(name: str, arguments: JsonDict, registry: dict[str, ToolSpec]) -> JsonDict:
167
+ """Execute a tool, returning a structured ``{"error": ...}`` result on failure.
168
+
169
+ Surfacing the error to the model (rather than raising) lets the agent recover
170
+ from a hallucinated tool name or a malformed argument on the next turn.
171
+ """
172
+ if name not in registry:
173
+ return {"error": f"unknown tool {name!r}; available: {sorted(registry)}"}
174
+ missing = _missing_required(registry[name], arguments)
175
+ if missing:
176
+ return {"error": f"missing required arguments for {name!r}: {missing}"}
177
+ try:
178
+ return call_tool(name, arguments, registry)
179
+ except Exception as exc: # report any tool failure back to the model
180
+ return {"error": f"{type(exc).__name__}: {exc}"}
181
+
182
+
183
+ def _missing_required(spec: ToolSpec, arguments: JsonDict) -> list[str]:
184
+ """Names of required schema parameters absent from ``arguments``."""
185
+ required = spec.parameters.get("required", [])
186
+ return [key for key in required if key not in arguments]
187
+
188
+
189
+ def llm_planner(
190
+ provider: LLMProvider,
191
+ *,
192
+ system: str = DEFAULT_SYSTEM_PROMPT,
193
+ temperature: float = 0.0,
194
+ max_tokens: int = 1024,
195
+ ) -> Planner:
196
+ """Adapt an `LLMProvider` into a `Planner` for `run_agent`.
197
+
198
+ On each call it reconstructs the conversation from the goal and transcript,
199
+ asks the model for the next step, and maps the first requested tool call to a
200
+ ``{"tool", "arguments"}`` decision (or the text reply to ``final_answer``).
201
+ """
202
+
203
+ def planner(goal: str, schemas: list[JsonDict], transcript: list[JsonDict]) -> JsonDict:
204
+ messages = _rebuild_messages(goal, transcript, system)
205
+ reply = provider.chat(
206
+ messages, tools=schemas, temperature=temperature, max_tokens=max_tokens
207
+ )
208
+ if reply.has_tool_calls:
209
+ call = reply.tool_calls[0]
210
+ return {"tool": call.name, "arguments": call.arguments}
211
+ return {"final_answer": reply.content}
212
+
213
+ return planner
214
+
215
+
216
+ def _rebuild_messages(goal: str, transcript: list[JsonDict], system: str) -> list[Message]:
217
+ """Reconstruct a chat history (with synthetic call ids) from a flat transcript."""
218
+ messages: list[Message] = [Message.system(system), Message.user(goal)]
219
+ for i, entry in enumerate(transcript):
220
+ call_id = f"call_{i}"
221
+ messages.append(
222
+ Message.assistant(
223
+ "", [ToolCall(id=call_id, name=entry["tool"], arguments=entry["arguments"])]
224
+ )
225
+ )
226
+ messages.append(Message.tool(json.dumps(entry["result"]), call_id, entry["tool"]))
227
+ return messages
228
+
229
+
230
+ def heuristic_planner(rules: Sequence[tuple[str, JsonDict]], *, default_answer: str) -> Planner:
231
+ """A deterministic keyword planner: fire the first rule whose keyword is in the goal.
232
+
233
+ Each rule is ``(keyword, decision)``; the first ``keyword`` found in the goal
234
+ (case-insensitive) triggers its ``decision`` once. After its tool runs (or if
235
+ no rule matches), the planner returns ``default_answer``. Useful for offline
236
+ demos and tests without an LLM.
237
+ """
238
+
239
+ def planner(goal: str, schemas: list[JsonDict], transcript: list[JsonDict]) -> JsonDict:
240
+ if transcript:
241
+ return {"final_answer": default_answer}
242
+ lowered = goal.lower()
243
+ for keyword, decision in rules:
244
+ if keyword.lower() in lowered:
245
+ return decision
246
+ return {"final_answer": default_answer}
247
+
248
+ return planner