fugacio-copilot 0.0.1__py3-none-any.whl

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.
@@ -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