From 2ba8096a3cceecdcef72e63a7a7a736efff5426a Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Wed, 30 Sep 2026 08:20:44 -0500 Subject: [PATCH] wip(backend/tool): tool collection and execution is now agents job --- backend/src/agent.py | 12 +++ backend/src/agents/expert.py | 178 ++++++++++++++++++----------------- 2 files changed, 103 insertions(+), 87 deletions(-) diff --git a/backend/src/agent.py b/backend/src/agent.py index 03c407c..4f4910b 100644 --- a/backend/src/agent.py +++ b/backend/src/agent.py @@ -183,6 +183,18 @@ class Agent: return [r for r in self.resources if r.kind == kind and (key is None or r.key == key)] + def get_tools(self, run=None): + """Collect the tools available to this agent. + + Returns the agent-configured tools (derived from its resources) plus + any remote tools referenced by the run input. When a run is given, + remote names in ``run.input['remotes']`` are resolved and appended. + """ + tools = list(self.tools) + if run is not None and 'remotes' in run.input: + tools += self.app.getRemoteTools(self.user_id, run.input['remotes']) + return tools + class DebugAgent(Agent): identifier = "debug" diff --git a/backend/src/agents/expert.py b/backend/src/agents/expert.py index 534fdc7..a8342eb 100644 --- a/backend/src/agents/expert.py +++ b/backend/src/agents/expert.py @@ -8,6 +8,77 @@ Expert AI with customizable system prompt. Will get guidelines, documents, and other advanced features in the future. """.strip() +# This needs to move to inference, just like build_messages +async def chat_completion_loop(app, run, messages, model_id=None, tools=None): + """Drive ``inference.chat_completion`` until the model is finished. + + Wires the run's output / reasoning / tool-call events, executes requested + tools when ``tool_policy`` is 'auto', and feeds tool results back into + ``messages`` (mutated in place) so the model can continue. Returns once the + model produces a final answer with no further tool calls. + """ + tools = list(tools or []) + execute_tools = run.input.get('tool_policy', 'auto') == 'auto' + + tool_calls = [] + output_stream = None + reasoning_stream = None + inference_end = asyncio.Event() + + def on_output(stream): + nonlocal output_stream + output_stream = stream + + def on_reasoning(stream): + nonlocal reasoning_stream + reasoning_stream = stream + + run.on('output', on_output) + run.on('reasoning', on_reasoning) + run.on('tool-call', tool_calls.append) + run.on('inference-end', lambda _: inference_end.set()) + + while True: + # Reset per-iteration state + output_stream = None + reasoning_stream = None + inference_end.clear() + + await inference.chat_completion( + app, + model_id=model_id, + messages=messages, + tools=tools, + agent_run=run, + ) + + await inference_end.wait() + + content = output_stream and await output_stream.content_wait() + reasoning = reasoning_stream and await reasoning_stream.content_wait() + + if content or reasoning: + msg = inference.LlamaMessage('assistant', content or "") + if reasoning: + msg.reasoning = reasoning + messages.append(msg) + + if execute_tools and tool_calls: + for tc in tool_calls: + tool = next((t for t in tools if t.name == tc.tool_name), None) + if tool is None: + print(f"[agent] unknown tool name: {tc.tool_name}") + continue + tc.result = await tool.execute_async(**tc.arguments) + messages.append(inference.LlamaMessage('tool', tc)) + tool_calls.clear() + # Buffer time to let the provider rest before the next call + await asyncio.sleep(2) + elif content or not reasoning: + break + + run.close() + class ExpertAgent(Agent): identifier = "expert" description = CLASS_DESCRIPTION @@ -26,36 +97,22 @@ class ExpertAgent(Agent): Agent.__init__(self, *args) async def run(self, run): - opts = {} + # Apply agent configuration overrides to the run input + for key in ("temperature", "repetition_penalty", "reasoning_effort"): + if self.config.get(key) is not None: + run.input[key] = self.config[key] - # Is this roon using *Agent's* tools, as opposed to run tools - using_agent_tools = False - tools = run.input.get('tools', []) + # Provide the agent's default tools unless the run supplies its own. + # Only agent-provided tools are executable within the loop. + tools = run.input.get("tools", []) + if tools: + run.input['tool_policy'] = 'ignore' + else: + tools += self.get_tools(run) - if not tools: - # Agent tools will fully replace run tools if none are present, - # otherwise they won't be used - if self.tools: - tools = self.tools - using_agent_tools = True + print("Final tool object: " + repr(run.input.get('tools', []))) - # Inject remotes as tools - if 'remotes' in run.input: - tools += self.app.getRemoteTools(self.user_id, run.input['remotes']) - using_agent_tools = True - - print("Final tool object: " + repr(tools)) - if tools: run.input['tools'] = tools - - if self.config.get("temperature") is not None: - run.input["temperature"] = self.config["temperature"] - if self.config.get("repetition_penalty") is not None: - run.input["repetition_penalty"] = self.config["repetition_penalty"] - if self.config.get("reasoning_effort") is not None: - run.input["reasoning_effort"] = self.config["reasoning_effort"] - - - # Build message list based on input + # Build the initial message list messages = inference.build_messages( system_prompt=self.config.get("system_prompt", ""), instruction=run.input.get("instruction"), @@ -63,63 +120,10 @@ class ExpertAgent(Agent): messages=run.input.get("messages"), ) - # Collect inference output. These are resused across calls - tool_calls = [] - output_stream = None - reasoning_stream = None - inference_end = asyncio.Event() - def on_output(stream): - nonlocal output_stream - output_stream = stream - def on_reasoning(stream): - nonlocal reasoning_stream - reasoning_stream = stream - run.on('output', on_output) - run.on('reasoning', on_reasoning) - run.on('inference-end', lambda event: inference_end.set()) - run.on('tool-call', lambda tc: print(f"[expert] tool call: {tc}") or tool_calls.append(tc)) - - while True: - # Reset data - inference_end.clear() - output_stream = None - - # Call inference, passing the run to get events out - await inference.chat_completion( - self.app, - model_id=self.config.get("model"), - messages=messages, - tools=run.input.get("tools", []), - opts=opts, - agent_run=run, - ) - - await inference_end.wait() - - content = output_stream and await output_stream.content_wait(); - reasoning = reasoning_stream and await reasoning_stream.content_wait(); - - if content or reasoning: - msg = inference.LlamaMessage('assistant', content or "") - if reasoning: msg.reasoning = reasoning - messages.append(msg) - print(f"[expert] collected tool calls: {len(tool_calls)}, using? {using_agent_tools}") - - if using_agent_tools and tool_calls: - print("Will execute tool calls") - for tc in tool_calls: - try: - tool = next(t for t in tools if t.name == tc.tool_name) - if not tool: raise StopIteration() - tc.result = await tool.execute_async(**tc.arguments) - messages.append(inference.LlamaMessage('tool', tc)) - except StopIteration: - print(f"Bad agent tool name: {tc.name}") - tool_calls = [] - # Buffer time to let openrouter rest? - await asyncio.sleep(2) - elif content or not reasoning: - print("No tool calls to execute, finish") - break - - run.close() + await chat_completion_loop( + self.app, + run, + messages, + model_id=self.config.get("model"), + tools=tools, + )