razaali10 commited on
Commit
45615da
·
verified ·
1 Parent(s): cbd5fe5

Add agent.py

Browse files
Files changed (1) hide show
  1. agent.py +242 -0
agent.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Built-in agent: an LLM tool-use loop over the SWMM tool registry.
2
+
3
+ For platforms that are not MCP clients (plain REST callers, n8n HTTP nodes,
4
+ Custom GPT Actions, simple webhooks), this provides a single "ask the agent"
5
+ endpoint. MCP-native clients (Claude Desktop/web, Gemini, LangChain,
6
+ Flowise, Langflow) should normally drive the tools directly instead — their
7
+ own model is the agent.
8
+
9
+ Providers (two wire dialects, both via httpx, no SDK dependencies):
10
+ anthropic -> Anthropic Messages API (ANTHROPIC_API_KEY)
11
+ openai -> OpenAI chat completions (OPENAI_API_KEY)
12
+ gemini -> Gemini OpenAI-compatible endpoint (GEMINI_API_KEY)
13
+ groq -> Groq OpenAI-compatible endpoint (GROQ_API_KEY)
14
+ mistral -> Mistral OpenAI-compatible endpoint (MISTRAL_API_KEY)
15
+ local -> any OpenAI-compatible server (Ollama, LM Studio, vLLM) via
16
+ base_url; api_key optional
17
+
18
+ Keys come from environment (HF Space secrets) or per-request overrides.
19
+ Every response includes the full tool-call audit trail.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import inspect
24
+ import json
25
+ import os
26
+ import time
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+ from pcswmm_tools import PCSWMM_TOOL_REGISTRY as TOOL_REGISTRY
32
+
33
+ MAX_STEPS = 8
34
+ TOOL_RESULT_CHAR_LIMIT = 14000
35
+
36
+ SYSTEM_PROMPT = """You are the optional narrative agent for the local PCSWMM Engineering MCP.
37
+
38
+ Rules of practice:
39
+ - The user works in PCSWMM. Start from a connected PCSWMM session; do not ask for a generic standalone INP upload.
40
+ - Work only from deterministic tool evidence. Never invent numbers or treat unavailable values as zero.
41
+ - Preserve separation between PCSWMM evidence and the independent local SWMM verification. Reconcile differences before drawing conclusions.
42
+ - Distinguish screening from confirmed project criteria. Use pass/fail language only when a criterion is explicitly configured.
43
+ - For revised/final submissions, require revision evidence and an evidence-linked City-comment response matrix before claiming readiness.
44
+ - Report source tool names for important findings and state that professional engineering review remains required.
45
+ - The normal first tool is connect_active_pcswmm_project; reuse an existing session_id whenever supplied.
46
+ """
47
+
48
+ PROVIDER_PRESETS: dict[str, dict[str, str]] = {
49
+ "anthropic": {"dialect": "anthropic", "base_url": "https://api.anthropic.com",
50
+ "env": "ANTHROPIC_API_KEY", "default_model": "claude-sonnet-4-5"},
51
+ "openai": {"dialect": "openai", "base_url": "https://api.openai.com/v1",
52
+ "env": "OPENAI_API_KEY", "default_model": "gpt-4o"},
53
+ "gemini": {"dialect": "openai", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
54
+ "env": "GEMINI_API_KEY", "default_model": "gemini-2.0-flash"},
55
+ "groq": {"dialect": "openai", "base_url": "https://api.groq.com/openai/v1",
56
+ "env": "GROQ_API_KEY", "default_model": "llama-3.3-70b-versatile"},
57
+ "mistral": {"dialect": "openai", "base_url": "https://api.mistral.ai/v1",
58
+ "env": "MISTRAL_API_KEY", "default_model": "mistral-large-latest"},
59
+ "local": {"dialect": "openai", "base_url": os.environ.get("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1"),
60
+ "env": "LOCAL_LLM_API_KEY", "default_model": os.environ.get("LOCAL_LLM_MODEL", "llama3.1")},
61
+ }
62
+
63
+ # Tools the agent may call. upload_model is included so callers can pass INP
64
+ # content inline; generate_report excluded by default (large side effects)
65
+ # unless allow_report=True.
66
+ AGENT_TOOLS_DEFAULT = [
67
+ "connect_active_pcswmm_project",
68
+ "validate_active_pcswmm_project",
69
+ "get_active_pcswmm_project",
70
+ "run_independent_pcswmm_verification",
71
+ "review_active_pcswmm_model",
72
+ "get_pcswmm_node_results",
73
+ "get_pcswmm_link_results",
74
+ "get_pcswmm_subcatchment_results",
75
+ "get_pcswmm_timeseries",
76
+ "review_pcswmm_revision",
77
+ "configure_pcswmm_submission",
78
+ "set_pcswmm_city_comments",
79
+ "build_pcswmm_city_response_matrix",
80
+ "get_pcswmm_submission_readiness",
81
+ "set_pcswmm_report_details",
82
+ "set_pcswmm_report_configuration",
83
+ ]
84
+ _JSON_TYPES = {str: "string", int: "integer", float: "number", bool: "boolean",
85
+ dict: "object", list: "array"}
86
+
87
+
88
+ def _tool_schemas(names: list[str]) -> list[dict[str, Any]]:
89
+ schemas = []
90
+ for name in names:
91
+ fn = TOOL_REGISTRY.get(name)
92
+ if fn is None:
93
+ continue
94
+ sig = inspect.signature(fn)
95
+ props, required = {}, []
96
+ for pname, param in sig.parameters.items():
97
+ ann = param.annotation
98
+ jtype = "string"
99
+ for py, js in _JSON_TYPES.items():
100
+ if ann is py:
101
+ jtype = js
102
+ break
103
+ if ann in (dict | str | None, dict | str):
104
+ jtype = "object"
105
+ props[pname] = {"type": jtype}
106
+ if param.default is inspect.Parameter.empty:
107
+ required.append(pname)
108
+ schemas.append({"name": name,
109
+ "description": (fn.__doc__ or name).strip()[:900],
110
+ "input_schema": {"type": "object", "properties": props, "required": required}})
111
+ return schemas
112
+
113
+
114
+ def _execute(name: str, arguments: dict[str, Any]) -> str:
115
+ fn = TOOL_REGISTRY.get(name)
116
+ if fn is None:
117
+ return json.dumps({"error": f"unknown tool {name}"})
118
+ try:
119
+ result = fn(**(arguments or {}))
120
+ text = json.dumps(result, default=str)
121
+ except Exception as exc: # deterministic error surface for the model
122
+ text = json.dumps({"error": f"{type(exc).__name__}: {exc}"})
123
+ if len(text) > TOOL_RESULT_CHAR_LIMIT:
124
+ text = text[:TOOL_RESULT_CHAR_LIMIT] + '... (truncated — request a smaller limit or use query_results)"}'
125
+ return text
126
+
127
+
128
+ class LLMClient:
129
+ """Minimal two-dialect chat client. `transport` is injectable for tests."""
130
+
131
+ def __init__(self, provider: str, model: str | None = None, api_key: str | None = None,
132
+ base_url: str | None = None, transport: Any | None = None):
133
+ preset = PROVIDER_PRESETS.get(provider)
134
+ if preset is None:
135
+ raise ValueError(f"Unknown provider '{provider}'. Choose from {sorted(PROVIDER_PRESETS)}.")
136
+ self.provider = provider
137
+ self.dialect = preset["dialect"]
138
+ self.base_url = (base_url or preset["base_url"]).rstrip("/")
139
+ self.model = model or preset["default_model"]
140
+ self.api_key = api_key or os.environ.get(preset["env"], "")
141
+ if not self.api_key and provider != "local":
142
+ raise ValueError(
143
+ f"No API key for provider '{provider}'. Set the {preset['env']} Space secret "
144
+ "or pass api_key in the request.")
145
+ self._transport = transport
146
+
147
+ def chat(self, messages: list[dict], tools: list[dict]) -> dict:
148
+ if self._transport is not None:
149
+ return self._transport(self, messages, tools)
150
+ if self.dialect == "anthropic":
151
+ return self._chat_anthropic(messages, tools)
152
+ return self._chat_openai(messages, tools)
153
+
154
+ def _chat_anthropic(self, messages: list[dict], tools: list[dict]) -> dict:
155
+ resp = httpx.post(
156
+ f"{self.base_url}/v1/messages",
157
+ headers={"x-api-key": self.api_key, "anthropic-version": "2023-06-01"},
158
+ json={"model": self.model, "max_tokens": 2000, "system": SYSTEM_PROMPT,
159
+ "messages": messages, "tools": tools},
160
+ timeout=120.0)
161
+ resp.raise_for_status()
162
+ data = resp.json()
163
+ calls = [{"id": b["id"], "name": b["name"], "arguments": b["input"]}
164
+ for b in data.get("content", []) if b.get("type") == "tool_use"]
165
+ text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
166
+ return {"text": text, "tool_calls": calls, "raw_content": data.get("content", []),
167
+ "stop": data.get("stop_reason")}
168
+
169
+ def _chat_openai(self, messages: list[dict], tools: list[dict]) -> dict:
170
+ oai_tools = [{"type": "function",
171
+ "function": {"name": t["name"], "description": t["description"],
172
+ "parameters": t["input_schema"]}} for t in tools]
173
+ oai_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages
174
+ headers = {"Content-Type": "application/json"}
175
+ if self.api_key:
176
+ headers["Authorization"] = f"Bearer {self.api_key}"
177
+ resp = httpx.post(f"{self.base_url}/chat/completions", headers=headers,
178
+ json={"model": self.model, "messages": oai_messages,
179
+ "tools": oai_tools or None}, timeout=120.0)
180
+ resp.raise_for_status()
181
+ msg = resp.json()["choices"][0]["message"]
182
+ calls = [{"id": c["id"], "name": c["function"]["name"],
183
+ "arguments": json.loads(c["function"]["arguments"] or "{}")}
184
+ for c in (msg.get("tool_calls") or [])]
185
+ return {"text": msg.get("content") or "", "tool_calls": calls,
186
+ "raw_message": msg, "stop": "tool_use" if calls else "end"}
187
+
188
+
189
+ def run_agent(question: str, provider: str = "local", model: str | None = None,
190
+ api_key: str | None = None, base_url: str | None = None,
191
+ session_id: str | None = None, inp_content: str | None = None,
192
+ allow_report: bool = False, max_steps: int = MAX_STEPS,
193
+ transport: Any | None = None) -> dict:
194
+ """Run the tool-use loop and return {answer, tool_trace, steps, provider}."""
195
+ client = LLMClient(provider, model, api_key, base_url, transport)
196
+ tool_names = list(AGENT_TOOLS_DEFAULT) + (["generate_pcswmm_swmr", "close_pcswmm_session"] if allow_report else [])
197
+ tools = _tool_schemas(tool_names)
198
+
199
+ user_text = question
200
+ if session_id:
201
+ user_text += f"\n\n(Existing session_id: {session_id})"
202
+ if inp_content:
203
+ user_text += "\n\nA PCSWMM Engineering SDK package was provided inline. Connect it using connect_active_pcswmm_project.\n<pcswmm_package>\n" + inp_content[:400000] + "\n</pcswmm_package>"
204
+
205
+ trace: list[dict[str, Any]] = []
206
+ if client.dialect == "anthropic":
207
+ messages: list[dict] = [{"role": "user", "content": user_text}]
208
+ for step in range(max_steps):
209
+ reply = client.chat(messages, tools)
210
+ if not reply["tool_calls"]:
211
+ return {"answer": reply["text"], "tool_trace": trace, "steps": step + 1,
212
+ "provider": provider, "model": client.model}
213
+ messages.append({"role": "assistant", "content": reply["raw_content"]})
214
+ results_content = []
215
+ for call in reply["tool_calls"]:
216
+ t0 = time.time()
217
+ output = _execute(call["name"], call["arguments"])
218
+ trace.append({"tool": call["name"], "arguments": call["arguments"],
219
+ "elapsed_s": round(time.time() - t0, 2),
220
+ "result_preview": output[:400]})
221
+ results_content.append({"type": "tool_result", "tool_use_id": call["id"],
222
+ "content": output})
223
+ messages.append({"role": "user", "content": results_content})
224
+ else:
225
+ messages = [{"role": "user", "content": user_text}]
226
+ for step in range(max_steps):
227
+ reply = client.chat(messages, tools)
228
+ if not reply["tool_calls"]:
229
+ return {"answer": reply["text"], "tool_trace": trace, "steps": step + 1,
230
+ "provider": provider, "model": client.model}
231
+ messages.append(reply["raw_message"])
232
+ for call in reply["tool_calls"]:
233
+ t0 = time.time()
234
+ output = _execute(call["name"], call["arguments"])
235
+ trace.append({"tool": call["name"], "arguments": call["arguments"],
236
+ "elapsed_s": round(time.time() - t0, 2),
237
+ "result_preview": output[:400]})
238
+ messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})
239
+
240
+ return {"answer": "Agent reached the maximum number of steps without a final answer. "
241
+ "Partial evidence is in tool_trace.",
242
+ "tool_trace": trace, "steps": max_steps, "provider": provider, "model": client.model}