Tool calling added.
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user