Tool calling added.

This commit is contained in:
vh
2026-04-03 09:01:01 -07:00
parent 46b10e8420
commit 2166af01be
11 changed files with 1492 additions and 0 deletions
+145
View File
@@ -19,6 +19,7 @@ from datetime import datetime
import config
import task_store
import tools as _tools
import ui
# ---------------------------------------------------------------------------
@@ -51,6 +52,14 @@ class ProviderClient:
response = await self.call_async(system, messages, model, temperature, max_tokens)
yield ('content', response)
async def call_async_with_tools(self, system: str, messages: list[dict], model: str,
temperature: float, max_tokens: int,
tools: list[dict]) -> "_ToolMessage":
"""Non-streaming call with tool schemas. Returns a _ToolMessage.
Base implementation ignores tools and wraps the text response."""
text = await self.call_async(system, messages, model, temperature, max_tokens)
return _ToolMessage(content=text, tool_calls=None)
# --- OpenAI and any OpenAI-compatible endpoint ---
# Covers AIPA_PROVIDER / {AGENT}_PROVIDER = openai or openai_compatible.
@@ -78,6 +87,24 @@ class OpenAIClient(ProviderClient):
self.call_async(system, messages, model, temperature, max_tokens)
)
async def call_async_with_tools(self, system, messages, model, temperature, max_tokens, tools):
"""Non-streaming call with tool schemas. Returns a _ToolMessage."""
full_messages = [{"role": "system", "content": system}] + messages
response = await self.client.chat.completions.create(
model=model,
temperature=temperature,
max_tokens=max_tokens,
messages=full_messages,
tools=tools,
tool_choice="auto",
extra_body=self.extra_body or None,
)
msg = response.choices[0].message
return _ToolMessage(
content=msg.content or "",
tool_calls=msg.tool_calls or None,
)
async def call_async_streaming(self, system, messages, model, temperature, max_tokens):
"""Stream via the OpenAI-compatible API, yielding thinking and content chunks.
Thinking tokens arrive in delta.reasoning_content (Qwen3, DeepSeek R1 style);
@@ -187,6 +214,13 @@ def build_client(agent_name: str) -> ProviderClient:
# Data Classes
# ---------------------------------------------------------------------------
@dataclass
class _ToolMessage:
"""Minimal message returned by call_async_with_tools."""
content: str
tool_calls: list | None # list of openai ToolCall objects, or None
@dataclass
class AgentState:
"""Runtime state for one named agent during a session."""
@@ -198,6 +232,7 @@ class AgentState:
stateful: bool
client: ProviderClient
history: list[dict] = field(default_factory=list)
tools: list[str] = field(default_factory=list) # tool names from agents.yaml
def add_user(self, content: str):
self.history.append({"role": "user", "content": content})
@@ -289,6 +324,7 @@ def build_agent(name: str) -> AgentState:
max_tokens=cfg["max_tokens"],
stateful=cfg["stateful"],
client=build_client(name),
tools=cfg.get("tools", []),
)
@@ -304,6 +340,87 @@ def new_task_id() -> str:
return f"T-{datetime.now().strftime('%Y%m%d')}-{_task_counter:03d}"
# ---------------------------------------------------------------------------
# Tool-Calling Loop
# ---------------------------------------------------------------------------
async def _run_tool_loop(
agent: AgentState,
stream_mode: str = "deliverable",
dry_run: bool = False,
) -> str:
"""
Agentic loop for tool-enabled agents.
Calls the agent with its tool schemas until the model stops issuing
tool_calls. Each tool invocation is displayed via ui.print_tool_call().
The final text response is streamed via stream_agent_output (so the caller
gets the same visual treatment as a regular streaming call).
The agent's history is updated with assistant tool-call messages and
tool result messages so that multi-turn tool use works correctly.
Returns the final content string.
"""
if dry_run:
return f"[DRY RUN] Would run tool loop for {agent.name}"
schemas = _tools.get_schemas(agent.tools)
# Iterate until the model stops calling tools
while True:
msg: _ToolMessage = await agent.client.call_async_with_tools(
system=agent.system_prompt,
messages=agent.messages_for_call(),
model=agent.model,
temperature=agent.temperature,
max_tokens=agent.max_tokens,
tools=schemas,
)
if not msg.tool_calls:
# No more tool calls — stream final content
final_content = msg.content
async def _emit_final():
yield ('content', final_content)
response = await ui.stream_agent_output(agent.name, _emit_final(), mode=stream_mode)
agent.add_assistant(response)
return response
# Append the assistant message with tool_calls to history
# Build a serialisable representation of the tool-calls message
tool_calls_repr = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
agent.history.append({
"role": "assistant",
"content": msg.content or "",
"tool_calls": tool_calls_repr,
})
# Execute each tool call and append results
for tc in msg.tool_calls:
tool_name = tc.function.name
args_json = tc.function.arguments
result_json = _tools.call(tool_name, args_json)
ui.print_tool_call(agent.name, tool_name, args_json, result_json)
agent.history.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result_json,
})
# ---------------------------------------------------------------------------
# Core Agent Caller
# Uses the agent's own client — no shared client passed in.
@@ -321,6 +438,13 @@ async def call_agent_async(
agent.add_user(user_message)
# If the agent has tools, use the tool-calling loop instead of streaming directly
if agent.tools:
try:
return await _run_tool_loop(agent, stream_mode=stream_mode, dry_run=dry_run)
except NotImplementedError as e:
raise RuntimeError(str(e)) from e
try:
if stream:
response = await ui.stream_agent_output(
@@ -687,6 +811,27 @@ async def cmd_history(session: Session, args: str) -> bool:
return True
@command(["/reload"], "Reload agents.yaml and rebuild the agent roster for this session")
async def cmd_reload(session: Session, args: str) -> bool:
ui.print_system("Reloading agents configuration…")
config.reload_agents_config()
# Rebuild all agents with fresh config
session.orchestrator = build_agent(config.ORCHESTRATOR_AGENT)
session.auditor = build_agent(config.AUDITOR_AGENT)
session.leads = {
name: build_agent(name) for name in config.LEAD_AGENT_NAMES
}
# Re-inject standing brief into orchestrator
brief = load_standing_brief()
session.orchestrator.system_prompt = (
session.orchestrator.system_prompt
+ "\n\n---\n\n## Standing Brief (current)\n\n"
+ brief
)
ui.print_system("Agents reloaded.")
return True
@command(["/thinking"], "Show the thinking output from the last response")
async def cmd_thinking(session: Session, args: str) -> bool:
last = ui.get_last_thinking()