AQIT 0.1.0
Loading...
Searching...
No Matches
agent.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2# This file is part of the Aquin Engine. Unauthorized copying, modification,
3# distribution, or use of this file, via any medium, is strictly prohibited.
4# Proprietary and confidential. See LICENSE for terms.
5
6from __future__ import annotations
7
8import json
9import uuid
10from pathlib import Path
11from typing import Any, Callable
12
13import httpx
14
15_SYSTEM_PROMPT_PATH = Path(__file__).parent / "prompts" / "system.txt"
16
17
18def _load_system_prompt(state: dict[str, Any]) -> str:
19 base = _SYSTEM_PROMPT_PATH.read_text(encoding="utf-8")
20
21 parts: list[str] = []
22 model_id = state.get("activeModelId") or state.get("model_id")
23 if model_id:
24 parts.append(f"Model: {model_id}")
25
26 session_mode = state.get("session_mode")
27 active_model = state.get("activeModelId")
28 if active_model:
29 try:
30 from aquin.session_mode import mode_for_model_id
31 session_mode = mode_for_model_id(str(active_model))
32 except ValueError:
33 pass
34 if session_mode == "llm" or active_model:
35 parts.append("Mode: LLM")
36 else:
37 parts.append("Mode: (no model loaded — ask user to run aquin load)")
38
39 last_prompt = state.get("lastPrompt")
40 if last_prompt:
41 parts.append(f'Last inspected prompt: "{last_prompt}"')
42
43 top_features = state.get("lastTopFeatures") or []
44 if top_features:
45 lines = [f" - F{f['feature_idx']} \"{f['label']}\" (act={f['activation']:.2f})" for f in top_features[:5]]
46 parts.append("Top features from last run:\n" + "\n".join(lines))
47
48 memory = state.get("memory") or {}
49 if memory:
50 mem_lines = [f" {k}: {v}" for k, v in list(memory.items())[:10]]
51 parts.append("Session memory:\n" + "\n".join(mem_lines))
52
53 if parts:
54 base += "\n\n[Current context]\n" + "\n".join(parts)
55
56 return base
57
58
59def _build_tools_schema(state: dict[str, Any]) -> list[dict[str, Any]]:
60 """Return OpenAI-format tool definitions for the active session mode."""
61 try:
62 from aquin.session_mode import get_filtered_tool_schemas, mode_for_active_model, mode_for_model_id
64 model_id = state.get("activeModelId")
65 if model_id:
66 try:
67 mode = mode_for_model_id(str(model_id))
68 except ValueError:
69 mode = mode_for_active_model()
70 else:
71 mode = mode_for_active_model()
72 if mode is None:
73 return []
74 return get_filtered_tool_schemas(mode)
75 except Exception:
76 return []
77
78
80 session_id: str,
81 user_message: str,
82 state: dict[str, Any],
83 api_key: str,
84 base_url: str,
85 on_token: Callable[[str], None] | None = None,
86 on_tool_start: Callable[[str, dict], None] | None = None,
87 on_tool_result: Callable[[str, Any, bool], None] | None = None,
88) -> None:
89 """
90 Run one user turn through the orchestrator loop.
91
92 Streams text as message.append events and dispatches tool calls via the
93 registry, pushing tool.start + tool.result for each. Loops until the model
94 stops requesting tool calls.
95 """
96 from aquin.engine.sync import push_events
97 from aquin.engine.tools import dispatch as dispatch_tool # import via __init__ to trigger stubs registration
98
99 system_prompt = _load_system_prompt(state)
100 tools_schema = _build_tools_schema(state)
101
102 messages: list[dict[str, Any]] = [
103 {"role": "system", "content": system_prompt},
104 {"role": "user", "content": user_message},
105 ]
106
107 agent_url = f"{base_url.rstrip('/')}/api/sync/openai/chat"
108 assistant_msg_id = str(uuid.uuid4())
109
110 while True:
111 # ── Stream the assistant response via Aquin proxy ────────────────────
112 assistant_text = ""
113 pending_tool_calls: list[dict[str, Any]] = []
114
115 request_body: dict[str, Any] = {
116 "model": "gpt-4o-mini",
117 "messages": messages,
118 }
119 if tools_schema:
120 request_body["tools"] = tools_schema
121 request_body["tool_choice"] = "auto"
122
123 with httpx.stream(
124 "POST",
125 agent_url,
126 headers={
127 "Authorization": f"Bearer {api_key}",
128 "Content-Type": "application/json",
129 },
130 json=request_body,
131 timeout=120,
132 verify=False,
133 ) as resp:
134 resp.raise_for_status()
135
136 # Accumulate streamed chunks
137 tool_call_accum: dict[int, dict[str, Any]] = {}
138
139 for line in resp.iter_lines():
140 if not line.startswith("data: "):
141 continue
142 raw = line[len("data: "):]
143 if raw == "[DONE]":
144 break
145 try:
146 chunk = json.loads(raw)
147 except json.JSONDecodeError:
148 continue
149
150 delta = chunk.get("choices", [{}])[0].get("delta", {})
151
152 # Text content — buffer locally, push to web only on flush
153 text_piece = delta.get("content") or ""
154 if text_piece:
155 assistant_text += text_piece
156 if on_token:
157 on_token(text_piece)
158 else:
159 print(text_piece, end="", flush=True)
160
161 # Tool call deltas
162 for tc_delta in delta.get("tool_calls") or []:
163 idx = tc_delta.get("index", 0)
164 if idx not in tool_call_accum:
165 tool_call_accum[idx] = {
166 "id": tc_delta.get("id", ""),
167 "type": "function",
168 "function": {"name": "", "arguments": ""},
169 }
170 acc = tool_call_accum[idx]
171 if tc_delta.get("id"):
172 acc["id"] = tc_delta["id"]
173 fn = tc_delta.get("function", {})
174 if fn.get("name"):
175 acc["function"]["name"] += fn["name"]
176 if fn.get("arguments"):
177 acc["function"]["arguments"] += fn["arguments"]
178
179 pending_tool_calls = list(tool_call_accum.values())
180
181 # ── Push completed assistant message to sync ──────────────────────────
182 if assistant_text:
183 push_events(
184 session_id=session_id,
185 events=[{
186 "type": "message.append",
187 "payload": {
188 "message": {
189 "id": assistant_msg_id,
190 "role": "assistant",
191 "content": assistant_text,
192 "toolCalls": [],
193 }
194 },
195 }],
196 api_key=api_key,
197 base_url=base_url,
198 )
199
200 # ── No tool calls — turn is done ─────────────────────────────────────
201 if not pending_tool_calls:
202 if assistant_text:
203 messages.append({"role": "assistant", "content": assistant_text})
204 break
205
206 # ── Add assistant message with tool_calls to history ─────────────────
207 messages.append({
208 "role": "assistant",
209 "content": assistant_text or None,
210 "tool_calls": pending_tool_calls,
211 })
212
213 # ── Dispatch each tool call ───────────────────────────────────────────
214 for tc in pending_tool_calls:
215 tool_name = tc["function"]["name"]
216 tool_call_id = tc["id"]
217
218 try:
219 args = json.loads(tc["function"]["arguments"] or "{}")
220 except json.JSONDecodeError:
221 args = {}
222
223 if on_tool_start:
224 on_tool_start(tool_name, args)
225
226 # Push tool.start
227 push_events(
228 session_id=session_id,
229 events=[{
230 "type": "tool.start",
231 "payload": {
232 "tool_call_id": tool_call_id,
233 "tool_name": tool_name,
234 "args": args,
235 },
236 }],
237 api_key=api_key,
238 base_url=base_url,
239 )
240
241 # Dispatch to registry
242 result: Any = None
243 try:
244 from aquin.session_mode import is_tool_allowed, mode_for_active_model, mode_for_model_id
245
246 model_id = state.get("activeModelId")
247 if model_id:
248 try:
249 mode = mode_for_model_id(str(model_id))
250 except ValueError:
251 mode = mode_for_active_model()
252 else:
253 mode = mode_for_active_model()
254 if mode is None:
255 raise NotImplementedError("Load a model first: aquin load --model <id>")
256 if not is_tool_allowed(tool_name, mode):
257 raise NotImplementedError(
258 f"Tool '{tool_name}' is not available in {mode} session mode"
259 )
260
261 result = dispatch_tool(tool_name, args, {
262 "session_id": session_id,
263 "api_key": api_key,
264 "base_url": base_url,
265 "state": state,
266 "tool_call_id": tool_call_id,
267 })
268 card = result.get("card") if isinstance(result, dict) else None
269 if card is None and isinstance(result, dict):
270 from aquin.compute import card_mapper
271 card = card_mapper.to_card(tool_name, result)
272 if isinstance(result, dict):
273 result_content = result.get("content") or {
274 k: v for k, v in result.items() if k not in ("card", "capture")
275 }
276 else:
277 result_content = result
278 if isinstance(result_content, dict):
279 from aquin.compute.sync_slim import slim_tool_result_for_sync
280 result_content = slim_tool_result_for_sync(tool_name, result_content, card)
281 except NotImplementedError as exc:
282 result_content = {"error": str(exc)}
283 card = None
284 result = None
285 except Exception as exc:
286 result_content = {"error": f"Tool '{tool_name}' failed: {exc}"}
287 card = None
288 result = None
289
290 if on_tool_result:
291 is_error = isinstance(result_content, dict) and "error" in result_content
292 on_tool_result(tool_name, result_content, is_error)
293
294 # Push tool.result
295 tool_result_payload: dict[str, Any] = {
296 "tool_call_id": tool_call_id,
297 "result": result_content,
298 }
299 if card is not None:
300 tool_result_payload["card"] = card
301
302 result_events: list[dict] = [{"type": "tool.result", "payload": tool_result_payload}]
303 if isinstance(result, dict) and result.get("capture"):
304 result_events.append({"type": "capture.ready", "payload": result["capture"]})
305
306 push_events(
307 session_id=session_id,
308 events=result_events,
309 api_key=api_key,
310 base_url=base_url,
311 timeout=90.0 if tool_name == "ensure_umap_loaded" else 15.0,
312 )
313
314 # Append tool result to message history for next loop iteration
315 messages.append({
316 "role": "tool",
317 "tool_call_id": tool_call_id,
318 "content": json.dumps(result_content) if not isinstance(result_content, str) else result_content,
319 })
320
321 # Re-assign a fresh message id for any subsequent assistant turn
322 assistant_msg_id = str(uuid.uuid4())
None run_agent_turn(str session_id, str user_message, dict[str, Any] state, str api_key, str base_url, Callable[[str], None]|None on_token=None, Callable[[str, dict], None]|None on_tool_start=None, Callable[[str, Any, bool], None]|None on_tool_result=None)
Definition agent.py:92
list[dict[str, Any]] _build_tools_schema(dict[str, Any] state)
Definition agent.py:63
str _load_system_prompt(dict[str, Any] state)
Definition agent.py:22