Skip to content

Commit 61cc46a

Browse files
authored
Fix supplied tool calls in ADK and Langroid Bots (#615)
1 parent 8279801 commit 61cc46a

7 files changed

Lines changed: 471 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,14 @@ jobs:
311311
. .venv-pydantic-ai/bin/activate
312312
python -m pip install --requirement agent-pydantic-ai/requirements.txt --requirement agent-pydantic-ai/requirements-test.txt
313313
python -m pytest agent-pydantic-ai/tests -q
314-
- name: Langroid model-choice regression
314+
- name: Google ADK tool-call regressions
315+
run: |
316+
set -euo pipefail
317+
python -m venv .venv-adk
318+
. .venv-adk/bin/activate
319+
python -m pip install --requirement agent-adk/requirements.txt --requirement agent-adk/requirements-test.txt
320+
python -m pytest agent-adk/tests -q
321+
- name: Langroid model-choice and tool-call regressions
315322
run: |
316323
set -euo pipefail
317324
python -m venv .venv-langroid

agent-adk/requirements-test.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
httpx==0.28.1
2+
pytest==9.0.2

agent-adk/src/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import os
88

9-
from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint
9+
from ag_ui_adk import ADKAgent, AGUIToolset, add_adk_fastapi_endpoint
1010
from fastapi import FastAPI, Request
1111
from fastapi.responses import JSONResponse
1212
from google.adk.agents import Agent
@@ -44,6 +44,7 @@ async def health():
4444
ADKAgent(
4545
adk_agent=Agent(
4646
name="openbot",
47+
tools=[AGUIToolset()],
4748
model=LiteLlm(model=_model_id()),
4849
instruction="Answer the question you are asked, briefly and correctly.",
4950
),

agent-adk/tests/test_main.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Real ADK/LiteLLM roundtrip; only the provider's HTTP responses are synthetic."""
2+
3+
import asyncio
4+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5+
import importlib
6+
import json
7+
from pathlib import Path
8+
import sys
9+
from threading import Thread
10+
11+
import httpx
12+
import pytest
13+
14+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15+
16+
TOKEN = "test-managed-token"
17+
TOOL_NAME = "read_probe_value"
18+
TOOL_VALUE = "value-returned-by-the-client-tool"
19+
TOOL = {
20+
"name": TOOL_NAME,
21+
"description": "Read a validation value.",
22+
"parameters": {
23+
"type": "object",
24+
"properties": {"request_id": {"type": "string"}},
25+
"required": ["request_id"],
26+
},
27+
}
28+
USER = {"id": "user", "role": "user", "content": "Read the probe value."}
29+
30+
31+
@pytest.fixture
32+
def provider():
33+
seen = []
34+
35+
class Provider(BaseHTTPRequestHandler):
36+
def log_message(self, *args):
37+
pass
38+
39+
def do_POST(self):
40+
body = json.loads(self.rfile.read(int(self.headers["content-length"])))
41+
seen.append((self.path, self.headers.get("authorization"), body))
42+
names = [tool["function"]["name"] for tool in body.get("tools", [])]
43+
results = [message for message in body["messages"] if message["role"] == "tool"]
44+
delta = {"role": "assistant", "content": "No callable tool was supplied."}
45+
reason = "stop"
46+
if results and TOOL_VALUE in json.dumps(results):
47+
delta = {"role": "assistant", "content": TOOL_VALUE}
48+
elif TOOL_NAME in names:
49+
delta = {
50+
"role": "assistant",
51+
"tool_calls": [{
52+
"index": 0,
53+
"id": "call_probe",
54+
"type": "function",
55+
"function": {"name": TOOL_NAME, "arguments": '{"request_id":"probe"}'},
56+
}],
57+
}
58+
reason = "tool_calls"
59+
base = {"id": "chat_probe", "object": "chat.completion.chunk", "created": 0, "model": body["model"]}
60+
chunks = [
61+
{**base, "choices": [{"index": 0, "delta": delta, "finish_reason": None}]},
62+
{**base, "choices": [{"index": 0, "delta": {}, "finish_reason": reason}]},
63+
]
64+
data = ("".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + "data: [DONE]\n\n").encode()
65+
self.send_response(200)
66+
self.send_header("Content-Type", "text/event-stream")
67+
self.send_header("Content-Length", str(len(data)))
68+
self.end_headers()
69+
self.wfile.write(data)
70+
71+
server = ThreadingHTTPServer(("127.0.0.1", 0), Provider)
72+
thread = Thread(target=server.serve_forever, daemon=True)
73+
thread.start()
74+
try:
75+
yield f"http://127.0.0.1:{server.server_port}/v1", seen
76+
finally:
77+
server.shutdown()
78+
server.server_close()
79+
thread.join(timeout=5)
80+
81+
82+
@pytest.fixture
83+
def harness(monkeypatch, provider):
84+
base_url, seen = provider
85+
for name, value in {
86+
"OPENAI_API_KEY": "synthetic-test-key",
87+
"OPENAI_BASE_URL": base_url,
88+
"BOT_PROVIDER": "openai",
89+
"BOT_MODEL": "gpt-4.1-mini",
90+
"MANAGED_AGENT_TOKEN": TOKEN,
91+
"OTEL_SDK_DISABLED": "true",
92+
}.items():
93+
monkeypatch.setenv(name, value)
94+
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
95+
from src import main
96+
97+
return importlib.reload(main).app, seen
98+
99+
100+
def decode(response):
101+
assert response.status_code == 200
102+
events = [json.loads(line[5:]) for line in response.text.splitlines() if line.startswith("data:")]
103+
assert not any(event["type"] == "RUN_ERROR" for event in events), events
104+
assert any(event["type"] == "RUN_FINISHED" for event in events), events
105+
return events
106+
107+
108+
def test_client_tool_executes_and_its_result_reaches_the_followup_model(harness):
109+
app, seen = harness
110+
executed = []
111+
112+
def read_probe_value(request_id):
113+
executed.append(request_id)
114+
assert request_id == "probe"
115+
return {"value": TOOL_VALUE}
116+
117+
async def roundtrip():
118+
body = {
119+
"threadId": "tool-roundtrip",
120+
"runId": "first-run",
121+
"state": {},
122+
"context": [],
123+
"forwardedProps": {},
124+
"messages": [USER],
125+
"tools": [TOOL],
126+
}
127+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://harness") as client:
128+
first = decode(await client.post("/", headers={"x-openbot-agent-token": TOKEN}, json=body))
129+
calls = [event for event in first if event["type"] == "TOOL_CALL_START"]
130+
assert len(calls) == 1, first
131+
call = calls[0]
132+
assert call["toolCallName"] == TOOL_NAME
133+
arguments = "".join(event["delta"] for event in first if event["type"] == "TOOL_CALL_ARGS" and event["toolCallId"] == call["toolCallId"])
134+
result = read_probe_value(**json.loads(arguments))
135+
body.update(runId="followup-run", messages=[
136+
USER,
137+
{"id": call.get("parentMessageId", "assistant-tool-call"), "role": "assistant", "toolCalls": [{
138+
"id": call["toolCallId"], "type": "function",
139+
"function": {"name": TOOL_NAME, "arguments": arguments},
140+
}]},
141+
{"id": "tool-result", "role": "tool", "toolCallId": call["toolCallId"], "content": json.dumps(result)},
142+
])
143+
return decode(await client.post("/", headers={"x-openbot-agent-token": TOKEN}, json=body))
144+
145+
final = asyncio.run(asyncio.wait_for(roundtrip(), timeout=30))
146+
assert executed == ["probe"]
147+
assert len(seen) == 2
148+
for path, authorization, body in seen:
149+
assert path == "/v1/chat/completions"
150+
assert authorization == "Bearer synthetic-test-key"
151+
assert body["model"] == "gpt-4.1-mini"
152+
tools = seen[0][2]["tools"]
153+
assert [tool["function"]["name"] for tool in tools] == [TOOL_NAME]
154+
assert tools[0]["function"]["parameters"]["properties"]["request_id"]["type"] == "string"
155+
assert tools[0]["function"]["parameters"]["required"] == ["request_id"]
156+
results = [message for message in seen[1][2]["messages"] if message["role"] == "tool"]
157+
assert len(results) == 1
158+
assert TOOL_VALUE in results[0]["content"]
159+
answer = "".join(event["delta"] for event in final if event["type"] == "TEXT_MESSAGE_CONTENT")
160+
assert answer == TOOL_VALUE
161+
162+
163+
@pytest.mark.parametrize("headers", [{}, {"x-openbot-agent-token": "wrong-token"}])
164+
def test_client_tools_still_require_the_server_token(harness, headers):
165+
app, seen = harness
166+
167+
async def rejected():
168+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://harness") as client:
169+
return await client.post("/", headers=headers, json={"tools": [TOOL]})
170+
171+
assert asyncio.run(rejected()).status_code == 401
172+
assert seen == []
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Supply AG-UI tool schemas and returned history to Langroid's public LLM API."""
2+
3+
from copy import deepcopy
4+
5+
from ag_ui.core import RunAgentInput, Tool
6+
from ag_ui_langroid import LangroidAgent
7+
from langroid import ChatAgent, ChatAgentConfig, ChatDocument, ToolMessage
8+
from langroid.language_models.base import LLMFunctionSpec, LLMMessage, OpenAIToolCall, Role
9+
10+
11+
def _tool_message(tool: Tool) -> type[ToolMessage]:
12+
class FrontendTool(ToolMessage):
13+
request: str = tool.name
14+
purpose: str = tool.description
15+
strict: bool = False
16+
17+
@classmethod
18+
def llm_function_schema(cls, request=False, defaults=True):
19+
return LLMFunctionSpec(
20+
name=tool.name,
21+
description=tool.description,
22+
parameters=deepcopy(tool.parameters),
23+
)
24+
25+
return FrontendTool
26+
27+
28+
def _messages(input_data: RunAgentInput, system_message: str) -> list[LLMMessage]:
29+
messages = [LLMMessage(role=Role.SYSTEM, content=system_message)]
30+
for message in input_data.messages:
31+
content = getattr(message, "content", None)
32+
if isinstance(content, list):
33+
content = "\n".join(part.text for part in content if part.type == "text")
34+
calls = getattr(message, "tool_calls", None)
35+
messages.append(
36+
LLMMessage(
37+
role=Role.SYSTEM if message.role == "developer" else Role(message.role),
38+
content=content,
39+
tool_call_id=getattr(message, "tool_call_id", None),
40+
tool_calls=[OpenAIToolCall.from_dict(call.model_dump()) for call in calls]
41+
if calls
42+
else None,
43+
)
44+
)
45+
return messages
46+
47+
48+
class _RequestAgent(ChatAgent):
49+
def __init__(self, config: ChatAgentConfig):
50+
super().__init__(config)
51+
self.input_messages: list[LLMMessage] = []
52+
self.response_ids: list[str] = []
53+
54+
def _clone_extra_state(self, new_agent):
55+
new_agent.input_messages = self.input_messages
56+
new_agent.response_ids = self.response_ids
57+
58+
def llm_response(self, message=None):
59+
response = self.llm_response_messages(self.input_messages)
60+
if response is not None:
61+
self.response_ids.append(response.id())
62+
return response
63+
64+
65+
class FrontendToolsAgent(LangroidAgent):
66+
async def run(self, input_data: RunAgentInput):
67+
# ag-ui-langroid 0.1.1 forwards only tool names and the latest user text.
68+
# https://github.com/ag-ui-protocol/ag-ui/tree/main/integrations/langroid
69+
# Keep its event encoding, but use each run's complete history and schemas.
70+
agent = _RequestAgent(self._agent.config.model_copy(deep=True))
71+
agent.input_messages = _messages(input_data, agent.config.system_message)
72+
for tool in input_data.tools:
73+
agent.enable_message(_tool_message(tool), use=True, handle=False)
74+
adapter = LangroidAgent(name=self.name, agent=agent)
75+
try:
76+
async for event in adapter.run(input_data):
77+
yield event
78+
finally:
79+
for response_id in agent.response_ids:
80+
ChatDocument.delete_id(response_id)

agent-langroid/src/main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import os
44

5-
from ag_ui_langroid import LangroidAgent, create_langroid_app
5+
from ag_ui_langroid import create_langroid_app
66
from fastapi import Request
77
from fastapi.responses import JSONResponse
88
from langroid import ChatAgent, ChatAgentConfig
99
from langroid.language_models import OpenAIGPTConfig
1010

11+
from .frontend_tools import FrontendToolsAgent
12+
1113
TOKEN_HEADER = "x-openbot-agent-token"
1214

1315

@@ -33,12 +35,15 @@ def _model_id() -> str:
3335

3436
agent = ChatAgent(
3537
ChatAgentConfig(
36-
llm=OpenAIGPTConfig(chat_model=_model_id()),
38+
llm=OpenAIGPTConfig(chat_model=_model_id(), parallel_tool_calls=False),
39+
add_to_registry=False,
40+
use_functions_api=True,
41+
use_tools=False,
3742
system_message="Answer the question you are asked, briefly and correctly.",
3843
)
3944
)
4045

41-
app = create_langroid_app(LangroidAgent(name="openbot", agent=agent))
46+
app = create_langroid_app(FrontendToolsAgent(name="openbot", agent=agent))
4247

4348

4449
@app.middleware("http")

0 commit comments

Comments
 (0)