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.
- fugacio_copilot-0.0.1/.gitignore +32 -0
- fugacio_copilot-0.0.1/PKG-INFO +80 -0
- fugacio_copilot-0.0.1/README.md +58 -0
- fugacio_copilot-0.0.1/pyproject.toml +43 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/__init__.py +86 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/agent.py +248 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/dynamics_tools.py +385 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/integration_tools.py +311 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/llm/__init__.py +32 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/llm/anthropic.py +104 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/llm/base.py +141 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/llm/mock.py +52 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/llm/openai.py +95 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/mpc_tools.py +458 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/py.typed +0 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/report.py +268 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/saft_tools.py +252 -0
- fugacio_copilot-0.0.1/src/fugacio/copilot/tools.py +2092 -0
- fugacio_copilot-0.0.1/tests/test_agent.py +57 -0
- fugacio_copilot-0.0.1/tests/test_copilot.py +10 -0
- fugacio_copilot-0.0.1/tests/test_design_tools.py +85 -0
- fugacio_copilot-0.0.1/tests/test_dynamics_tools.py +89 -0
- fugacio_copilot-0.0.1/tests/test_integration_tools.py +83 -0
- fugacio_copilot-0.0.1/tests/test_llm_agent.py +91 -0
- fugacio_copilot-0.0.1/tests/test_mpc_tools.py +120 -0
- fugacio_copilot-0.0.1/tests/test_nonideal_tools.py +164 -0
- fugacio_copilot-0.0.1/tests/test_property_tools.py +73 -0
- fugacio_copilot-0.0.1/tests/test_reaction_tools.py +162 -0
- fugacio_copilot-0.0.1/tests/test_report.py +66 -0
- fugacio_copilot-0.0.1/tests/test_saft_tools.py +82 -0
- fugacio_copilot-0.0.1/tests/test_steam_tools.py +90 -0
- 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
|