···11from abc import ABC, abstractmethod
22import asyncio
33+import json
34import logging
55+from dataclasses import dataclass
46from typing import Any, Literal
5768import anthropic
79from anthropic.types import TextBlock, ToolUseBlock
88-from pydantic import BaseModel
1010+import httpx
9111012from src.agent.prompt import build_system_prompt
1113from src.tools.executor import ToolExecutor
···1315logger = logging.getLogger(__name__)
141615171616-class Message(BaseModel):
1717- role: Literal["user", "assistant"]
1818- content: str
1818+@dataclass
1919+class AgentTextBlock:
2020+ text: str
2121+2222+2323+@dataclass
2424+class AgentToolUseBlock:
2525+ id: str
2626+ name: str
2727+ input: dict[str, Any]
2828+2929+3030+@dataclass
3131+class AgentResponse:
3232+ content: list[AgentTextBlock | AgentToolUseBlock]
3333+ stop_reason: Literal["end_turn", "tool_use"]
3434+ reasoning_content: str | None = None
193520362137class AgentClient(ABC):
···2541 messages: list[dict[str, Any]],
2642 system: str | None = None,
2743 tools: list[dict[str, Any]] | None = None,
2828- ) -> anthropic.types.Message:
4444+ ) -> AgentResponse:
2945 pass
30463147···4157 messages: list[dict[str, Any]],
4258 system: str | None = None,
4359 tools: list[dict[str, Any]] | None = None,
4444- ) -> anthropic.types.Message:
6060+ ) -> AgentResponse:
4561 system_text = system or build_system_prompt()
4662 kwargs: dict[str, Any] = {
4763 "model": self._model_name,
···5773 }
58745975 if tools:
6060- tools = [dict(t) for t in tools] # shallow copy
7676+ tools = [dict(t) for t in tools]
6177 tools[-1]["cache_control"] = {"type": "ephemeral"}
6278 kwargs["tools"] = tools
63796480 async with self._client.messages.stream(**kwargs) as stream: # type: ignore
6565- return await stream.get_final_message()
8181+ msg = await stream.get_final_message()
8282+8383+ content: list[AgentTextBlock | AgentToolUseBlock] = []
8484+ for block in msg.content:
8585+ if isinstance(block, TextBlock):
8686+ content.append(AgentTextBlock(text=block.text))
8787+ elif isinstance(block, ToolUseBlock):
8888+ content.append(
8989+ AgentToolUseBlock(
9090+ id=block.id,
9191+ name=block.name,
9292+ input=block.input, # type: ignore
9393+ )
9494+ )
9595+9696+ return AgentResponse(
9797+ content=content,
9898+ stop_reason=msg.stop_reason or "end_turn", # type: ignore TODO: fix this
9999+ )
100100+101101+102102+class OpenAICompatibleClient(AgentClient):
103103+ """client for openapi compatible apis like openai, moonshot, etc"""
104104+105105+ def __init__(self, api_key: str, model_name: str, endpoint: str) -> None:
106106+ self._api_key = api_key
107107+ self._model_name = model_name
108108+ self._endpoint = endpoint.rstrip("/")
109109+ self._http = httpx.AsyncClient(timeout=300.0)
110110+111111+ async def complete(
112112+ self,
113113+ messages: list[dict[str, Any]],
114114+ system: str | None = None,
115115+ tools: list[dict[str, Any]] | None = None,
116116+ ) -> AgentResponse:
117117+ oai_messages = self._convert_messages(messages, system or build_system_prompt())
118118+119119+ payload: dict[str, Any] = {
120120+ "model": self._model_name,
121121+ "messages": oai_messages,
122122+ "max_tokens": 16_000,
123123+ }
124124+125125+ if tools:
126126+ payload["tools"] = self._convert_tools(tools)
127127+128128+ resp = await self._http.post(
129129+ f"{self._endpoint}/chat/completions",
130130+ headers={
131131+ "Authorization": f"Bearer {self._api_key}",
132132+ "Content-Type": "application/json",
133133+ },
134134+ json=payload,
135135+ )
136136+ if not resp.is_success:
137137+ logger.error(
138138+ "API error %d: %s", resp.status_code, resp.text[:1000]
139139+ )
140140+ resp.raise_for_status()
141141+ data = resp.json()
142142+143143+ return self._parse_response(data)
144144+145145+ def _convert_messages(
146146+ self, messages: list[dict[str, Any]], system: str
147147+ ) -> list[dict[str, Any]]:
148148+ """for anthropic chats, we'll convert the outputs into a similar format"""
149149+ result: list[dict[str, Any]] = [{"role": "system", "content": system}]
150150+151151+ for msg in messages:
152152+ role = msg["role"]
153153+ content = msg["content"]
154154+155155+ if isinstance(content, str):
156156+ result.append({"role": role, "content": content})
157157+ elif isinstance(content, list):
158158+ if role == "assistant":
159159+ text_parts = []
160160+ tool_calls = []
161161+ for block in content:
162162+ if block.get("type") == "text":
163163+ text_parts.append(block["text"])
164164+ elif block.get("type") == "tool_use":
165165+ tool_calls.append(
166166+ {
167167+ "id": block["id"],
168168+ "type": "function",
169169+ "function": {
170170+ "name": block["name"],
171171+ "arguments": json.dumps(block["input"]),
172172+ },
173173+ }
174174+ )
175175+ oai_msg: dict[str, Any] = {"role": "assistant"}
176176+ if msg.get("reasoning_content"):
177177+ oai_msg["reasoning_content"] = msg["reasoning_content"]
178178+ # some openai-compatible apis reject content: null on
179179+ # assistant messages with tool_calls, so omit it when empty
180180+ if text_parts:
181181+ oai_msg["content"] = "\n".join(text_parts)
182182+ else:
183183+ oai_msg["content"] = ""
184184+ if tool_calls:
185185+ oai_msg["tool_calls"] = tool_calls
186186+ result.append(oai_msg)
187187+ elif role == "user":
188188+ if content and content[0].get("type") == "tool_result":
189189+ for block in content:
190190+ result.append(
191191+ {
192192+ "role": "tool",
193193+ "tool_call_id": block["tool_use_id"],
194194+ "content": block.get("content", ""),
195195+ }
196196+ )
197197+ else:
198198+ text = " ".join(b.get("text", str(b)) for b in content)
199199+ result.append({"role": "user", "content": text})
200200+201201+ return result
202202+203203+ def _convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
204204+ """convert anthropic tool defs to oai function calling format"""
205205+ result = []
206206+ for t in tools:
207207+ func: dict[str, Any] = {
208208+ "name": t["name"],
209209+ "description": t.get("description", ""),
210210+ }
211211+ if "input_schema" in t:
212212+ func["parameters"] = t["input_schema"]
213213+ result.append({"type": "function", "function": func})
214214+ return result
215215+216216+ def _parse_response(self, data: dict[str, Any]) -> AgentResponse:
217217+ """convert an oai chat completion resp to agentresponse"""
218218+ choice = data["choices"][0]
219219+ message = choice["message"]
220220+ finish_reason = choice.get("finish_reason", "stop")
221221+222222+ content: list[AgentTextBlock | AgentToolUseBlock] = []
223223+224224+ if message.get("content"):
225225+ content.append(AgentTextBlock(text=message["content"]))
226226+227227+ if message.get("tool_calls"):
228228+ for tc in message["tool_calls"]:
229229+ try:
230230+ args = json.loads(tc["function"]["arguments"])
231231+ except (json.JSONDecodeError, KeyError):
232232+ args = {}
233233+ content.append(
234234+ AgentToolUseBlock(
235235+ id=tc["id"],
236236+ name=tc["function"]["name"],
237237+ input=args,
238238+ )
239239+ )
240240+241241+ stop_reason = "tool_use" if finish_reason == "tool_calls" else "end_turn"
242242+ reasoning_content = message.get("reasoning_content")
243243+ return AgentResponse(content=content, stop_reason=stop_reason, reasoning_content=reasoning_content)
662446724568246MAX_TOOL_RESULT_LENGTH = 10_000
···74252 model_api: Literal["anthropic", "openai", "openapi"],
75253 model_name: str,
76254 model_api_key: str | None,
255255+ model_endpoint: str | None = None,
77256 tool_executor: ToolExecutor | None = None,
78257 ) -> None:
7979- if model_api != "anthropic":
8080- # TODO: implement other APIs
8181- raise NotImplementedError()
8282-8383- if model_api == "anthropic":
8484- assert model_api_key
8585- self._client = AnthropicClient(api_key=model_api_key, model_name=model_name)
258258+ match model_api:
259259+ case "anthropic":
260260+ assert model_api_key
261261+ self._client: AgentClient = AnthropicClient(
262262+ api_key=model_api_key, model_name=model_name
263263+ )
264264+ case "openai":
265265+ assert model_api_key
266266+ self._client = OpenAICompatibleClient(
267267+ api_key=model_api_key,
268268+ model_name=model_name,
269269+ endpoint="https://api.openai.com/v1",
270270+ )
271271+ case "openapi":
272272+ assert model_api_key
273273+ assert model_endpoint, "model_endpoint is required for openapi"
274274+ self._client = OpenAICompatibleClient(
275275+ api_key=model_api_key,
276276+ model_name=model_name,
277277+ endpoint=model_endpoint,
278278+ )
8627987280 self._tool_executor = tool_executor
88281 self._conversation: list[dict[str, Any]] = []
···94287 return None
95288 return [self._tool_executor.get_execute_code_tool_definition()]
962899797- async def _handle_tool_call(self, tool_use: ToolUseBlock) -> dict[str, Any]:
290290+ async def _handle_tool_call(self, tool_use: AgentToolUseBlock) -> dict[str, Any]:
98291 """handle a tool call from the model"""
99292 if tool_use.name == "execute_code" and self._tool_executor:
100100- code = tool_use.input.get("code", "") # type: ignore
101101- result = await self._tool_executor.execute_code(code) # type: ignore
293293+ code = tool_use.input.get("code", "")
294294+ result = await self._tool_executor.execute_code(code)
102295 return result
103296 else:
104297 return {"error": f"Unknown tool: {tool_use.name}"}
···117310 text_response = ""
118311119312 for block in resp.content:
120120- if isinstance(block, TextBlock):
313313+ if isinstance(block, AgentTextBlock):
121314 assistant_content.append({"type": "text", "text": block.text})
122315 text_response += block.text
123123- elif isinstance(block, ToolUseBlock):
316316+ elif isinstance(block, AgentToolUseBlock): # type: ignore TODO: for now this errors because there are no other types, but ignore for now
124317 assistant_content.append(
125318 {
126319 "type": "tool_use",
···130323 }
131324 )
132325133133- self._conversation.append(
134134- {"role": "assistant", "content": assistant_content}
135135- )
326326+ assistant_msg: dict[str, Any] = {"role": "assistant", "content": assistant_content}
327327+ if resp.reasoning_content:
328328+ assistant_msg["reasoning_content"] = resp.reasoning_content
329329+ self._conversation.append(assistant_msg)
136330137331 # find any tool calls that we need to handle
138332 if resp.stop_reason == "tool_use":
139333 tool_results: list[dict[str, Any]] = []
140334 for block in resp.content:
141141- if isinstance(block, ToolUseBlock):
142142- code = (
143143- block.input.get("code", "")
144144- if isinstance(block.input, dict) # type: ignore
145145- else ""
146146- )
335335+ if isinstance(block, AgentToolUseBlock):
336336+ code = block.input.get("code", "")
147337 logger.info("Tool call: %s\n%s", block.name, code)
148338 result = await self._handle_tool_call(block)
149339 is_error = "error" in result
···155345 )
156346 content_str = str(result)
157347 if len(content_str) > MAX_TOOL_RESULT_LENGTH:
158158- content_str = content_str[:MAX_TOOL_RESULT_LENGTH] + "\n... (truncated)"
348348+ content_str = (
349349+ content_str[:MAX_TOOL_RESULT_LENGTH]
350350+ + "\n... (truncated)"
351351+ )
159352160353 tool_results.append(
161354 {
···167360168361 self._conversation.append({"role": "user", "content": tool_results})
169362 else:
170170- # once there are no mroe tool calls, we proceed to the text response
363363+ # once there are no more tool calls, we proceed to the text response
171364 return text_response
172365173366 async def run(self):
+46-10
src/tools/executor.py
···130130 tools_path = DENO_DIR / "tools.ts"
131131 tools_path.write_text(tools_ts)
132132133133+ @staticmethod
134134+ def _kill_process(process: asyncio.subprocess.Process) -> None:
135135+ """kill a subprocess, ignoring errors if it's already dead"""
136136+ try:
137137+ process.kill()
138138+ except ProcessLookupError:
139139+ pass
140140+133141 async def _run_deno(self, script_path: str) -> dict[str, Any]:
134142 """run the input script in a deno subprocess"""
135143···172180 # calculate remaining time against the total execution deadline
173181 remaining = deadline - asyncio.get_event_loop().time()
174182 if remaining <= 0:
175175- process.kill()
183183+ self._kill_process(process)
176184 error = f"execution timed out after {MAX_EXECUTION_TIME:.0f} seconds (total)"
177185 break
178186···193201 # track total output size to prevent stdout flooding
194202 total_output_bytes += len(line)
195203 if total_output_bytes > MAX_OUTPUT_SIZE:
196196- process.kill()
204204+ self._kill_process(process)
197205 error = f"output exceeded {MAX_OUTPUT_SIZE} bytes, killed"
198206 break
199207···208216 if "__tool_call__" in message:
209217 tool_call_count += 1
210218 if tool_call_count > MAX_TOOL_CALLS:
211211- process.kill()
219219+ self._kill_process(process)
212220 error = f"exceeded maximum of {MAX_TOOL_CALLS} tool calls"
213221 break
214222···225233 logger.exception(f"Tool error: {tool_name}")
226234 response = json.dumps({"__tool_error__": str(e)})
227235228228- process.stdin.write((response + "\n").encode())
229229- await process.stdin.drain()
236236+ try:
237237+ process.stdin.write((response + "\n").encode())
238238+ await process.stdin.drain()
239239+ except (ConnectionResetError, BrokenPipeError):
240240+ error = f"deno process exited while sending tool result for {tool_name}"
241241+ break
230242231243 elif "__output__" in message:
232244 outputs.append(message["__output__"])
···239251240252 # make sure that we kill deno subprocess if the execution times out
241253 except asyncio.TimeoutError:
242242- process.kill()
254254+ self._kill_process(process)
243255 error = "execution timed out"
244256 # also kill it for any other exceptions we encounter
245257 except Exception as e:
246246- process.kill()
258258+ self._kill_process(process)
247259 error = str(e)
248260249261 await process.wait()
···273285 return result
274286275287 def get_execute_code_tool_definition(self) -> dict[str, Any]:
276276- """get the anthropic tool definition for execute_code, including all the docs for available backend tools"""
288288+ """get tool definition for execute_code, including all the docs for available backend tools"""
277289278290 if self._tool_definition is not None:
279291 return self._tool_definition
···290302{self._database_schema}
291303292304Use these exact column names when writing SQL queries. Do NOT guess column names.
305305+306306+## ClickHouse SQL Tips
307307+308308+- **DateTime filtering**: The `__timestamp` column is `DateTime64(3)`. Do NOT use raw ISO strings. Use `parseDateTimeBestEffort()`:
309309+ ```sql
310310+ WHERE __timestamp >= parseDateTimeBestEffort('2026-02-06 04:30:00')
311311+ ```
312312+ To compute a relative time in TypeScript, format it as `YYYY-MM-DD HH:MM:SS`:
313313+ ```typescript
314314+ const ts = new Date(Date.now() - 30 * 60 * 1000).toISOString().slice(0, 19).replace('T', ' ');
315315+ ```
316316+- **Array slicing**: ClickHouse does NOT support `array[1:5]` syntax. Use `arraySlice(array, offset, length)`:
317317+ ```sql
318318+ arraySlice(groupArray(DISTINCT UserId), 1, 5) as sample_accounts
319319+ ```
320320+- **Error handling**: When running multiple independent queries, use `Promise.allSettled()` instead of `Promise.all()` so one failure doesn't crash the rest. Check each result's `.status` field.
293321"""
294322295323 osprey_section = ""
···329357330358Example:
331359```typescript
332332-const result = await tools.clickhouse.query("SELECT count() FROM events");
333333-output(result);
360360+// format a relative timestamp for ClickHouse DateTime64 columns
361361+const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString().slice(0, 19).replace('T', ' ');
362362+363363+// run multiple independent queries safely with Promise.allSettled
364364+const results = await Promise.allSettled([
365365+ tools.clickhouse.query(`SELECT Count() as cnt FROM default.osprey_execution_results WHERE __timestamp >= parseDateTimeBestEffort('${{thirtyMinAgo}}') LIMIT 1`),
366366+ tools.clickhouse.query(`SELECT UserId, Count() as n FROM default.osprey_execution_results WHERE __timestamp >= parseDateTimeBestEffort('${{thirtyMinAgo}}') GROUP BY UserId ORDER BY n DESC LIMIT 10`),
367367+]);
368368+369369+output(results.map(r => r.status === 'fulfilled' ? r.value : r.reason?.message));
334370```
335371336372{tool_docs}{schema_section}{osprey_section}"""