Files
centdix b0ddcf31e4 ci: add path-gated AI agent + ai_evals smoke workflows (#9640)
* ci: add path-gated AI agent integration tests workflow

Runs integration_tests/ai_agent_tests against real LLM providers
(Anthropic/OpenAI/Google) only when AI-agent backend code or the tests
change, since runs make paid LLM calls. Adds a conftest fixture that
skips provider-parametrized cases whose API keys are absent, so CI
exercises only the providers it has secrets for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: add path-gated ai_evals global-mode smoke workflow

Runs the global AI chat eval (global-test1) across one cheap model per
provider (Anthropic/OpenAI/Google/DeepSeek) only when the eval harness or
copilot chat code change, since runs make paid LLM calls. Builds Windmill
CE from source as the AI proxy; global tools/drafts run in the Vitest
bridge. Gates on the deterministic draft pipeline (run succeeded +
produced a draft + used write_script), not the variable LLM judge score.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: run AI smokes on PR ready-for-review instead of every push

Switch the pull_request trigger from `synchronize` (every commit) to
`ready_for_review`, with a job guard skipping draft PRs, so the paid LLM
runs only fire when a PR is marked ready to merge (plus push-to-main and
manual dispatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai_evals): lazily load cli mode so non-cli evals skip the cli toolchain

The entrypoint eagerly imported modes/cli, which pulls the wmill CLI
guidance modules and their JSR deps (@cliffy/*). Global/flow/script/app
runs then crashed with "Cannot find module '@cliffy/ansi/colors'" when
the cli workspace deps were not installed. Import createCliModeRunner
dynamically inside runCliBenchmark instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai_agent): raise low max_completion_tokens to OpenAI's 16 minimum

OpenAI's /v1/responses rejects max_output_tokens < 16 with a 400, failing
test_low_max_tokens for openai. 16 still exercises a truncated response.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: run ai_evals workflow on Node 22 for the frontend undici 8.x dep

The Vitest bridge loads frontend/node_modules/undici@8.x, which requires
Node >=22.19; Node 20 failed with "webidl.util.markAsUncloneable is not a
function" when loading vitest.config.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai_evals): run frontend evals autonomously + give global-test1 more turns

Frontend evals (flow/script/app/global) ran the production chat prompt, which
assumes an interactive human — so cheaper models burned their turn budget
asking for confirmation, waiting for approval, or presenting a plan, sometimes
hitting maxTurns without producing a draft. Append a shared autonomy note in
baseEvalRunner (the path all frontend modes share, mirroring cli mode): act
directly on clear requests; only ask on genuinely ambiguous ones (preserving
the askUserQuestion cases). Also raise global-test1's maxTurns 8 -> 10 so a
model that over-explores still converges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(ai_evals): watch draft/prompt deps outside copilot/

The global eval runs production frontend code in-process, so the smoke's
behavior depends on files outside frontend/src/lib/components/copilot/**:
the draft model (userDraft.svelte.ts, userDraftDbSyncer.svelte.ts), script
inference (infer.ts), and the chat system prompts ($system_prompts ->
system_prompts/auto-generated). Add them to both push and PR path filters so
a change there actually triggers the smoke that gates on draft production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: skip direct provider tests without credentials

* feat: add ai evals skip judge flag

* fix: simplify ai evals ci gate

* fix: simplify ai evals smoke gate

* fix: handle ai eval workflow triggers

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:41:31 +02:00

252 lines
7.9 KiB
Python

"""
Tool calling tests for AI agents.
Tests AI agent tool calling with different tool types:
- Rawscript tools (inline Bun/TypeScript)
- Workspace script tools (scripts deployed to the workspace)
- MCP tools (external MCP servers)
- Websearch tools (built-in web search)
"""
import pytest
from .conftest import AIAgentTestClient, create_ai_agent_flow, create_rawscript_tool, create_script_tool
from .providers import ALL_PROVIDERS, ANTHROPIC, GOOGLE_AI, OPENAI, make_provider_input_transform
def get_provider_ids(providers: list) -> list[str]:
"""Get provider names for pytest parametrization IDs."""
return [p["name"] for p in providers]
# Inline script for sum tool (Bun/TypeScript)
ADD_NUMBERS_SCRIPT = """
export function main(a: number, b: number): number {
return a + b;
}
"""
GOOGLE_AI_GEMINI_3 = {
"name": "google_ai_gemini_3",
"input_transform": make_provider_input_transform(
kind="googleai",
model="gemini-3-flash-preview",
resource_path="u/admin/googleai",
),
}
class TestToolCalling:
"""Test AI agent tool calling with different tool types."""
@pytest.mark.parametrize(
"provider_config",
ALL_PROVIDERS,
ids=get_provider_ids(ALL_PROVIDERS),
)
def test_sum_tool(
self,
client: AIAgentTestClient,
setup_providers,
provider_config,
):
"""
Test that an AI agent can call a rawscript tool to add numbers.
"""
tools = [
create_rawscript_tool(
tool_id="add_numbers",
content=ADD_NUMBERS_SCRIPT,
params=["a", "b"],
language="bun",
)
]
flow_value = create_ai_agent_flow(
provider_input_transform=provider_config["input_transform"],
system_prompt="You are a helpful assistant. Use the add_numbers tool to perform arithmetic.",
tools=tools,
)
result = client.run_preview_flow(
flow_value=flow_value,
args={"user_message": "What is 5 + 7? Use the add_numbers tool."},
)
assert result is not None
result_str = str(result)
assert "12" in result_str, f"Expected '12' in result: {result}"
print(f"Sum tool result from {provider_config['name']}: {result}")
@pytest.mark.parametrize(
"provider_config",
ALL_PROVIDERS,
ids=get_provider_ids(ALL_PROVIDERS),
)
def test_workspace_script_tool(
self,
client: AIAgentTestClient,
setup_providers,
provider_config,
):
"""
Test that an AI agent can call a workspace script tool to add numbers.
This test uses a script that was deployed to the workspace (u/admin/sum_script)
rather than an inline rawscript.
"""
tools = [
create_script_tool(
tool_id="sum_numbers",
script_path="u/admin/sum_script",
params=["a", "b"],
)
]
flow_value = create_ai_agent_flow(
provider_input_transform=provider_config["input_transform"],
system_prompt="You are a helpful assistant. Use the sum_numbers tool to perform arithmetic.",
tools=tools,
)
result = client.run_preview_flow(
flow_value=flow_value,
args={"user_message": "What is 8 + 15? Use the sum_numbers tool."},
)
assert result is not None
result_str = str(result)
assert "23" in result_str, f"Expected '23' in result: {result}"
print(f"Workspace script tool result from {provider_config['name']}: {result}")
@pytest.mark.requires_provider("google_ai")
def test_nested_ai_agent_tool_with_gemini_3(
self,
client: AIAgentTestClient,
setup_providers,
):
"""
Test that a Gemini agent can call another Gemini AI agent as a tool.
"""
nested_ai_agent_tool = {
"id": "delegate_agent",
"summary": "delegate_agent",
"value": {
"tool_type": "flowmodule",
"type": "aiagent",
"input_transforms": {
"provider": GOOGLE_AI_GEMINI_3["input_transform"],
"system_prompt": {
"type": "static",
"value": "You are a concise arithmetic helper. Return only the numeric answer.",
},
"user_message": {"type": "ai"},
"output_type": {"type": "static", "value": "text"},
},
"tools": [],
},
}
flow_value = create_ai_agent_flow(
provider_input_transform=GOOGLE_AI_GEMINI_3["input_transform"],
system_prompt="You are a coordinator. Use delegate_agent for arithmetic before answering.",
tools=[nested_ai_agent_tool],
output_type="text",
)
result = client.run_preview_flow(
flow_value=flow_value,
args={"user_message": "Ask delegate_agent what 13 + 29 is, then tell me the result."},
)
assert result is not None
result_str = str(result)
assert "42" in result_str, f"Expected '42' in result: {result}"
messages = result.get("messages", [])
assert any(
tool_call.get("function", {}).get("name") == "delegate_agent"
for message in messages
for tool_call in message.get("tool_calls", [])
), f"Expected delegate_agent tool call in messages: {messages}"
print(f"Nested AI agent tool result from {GOOGLE_AI_GEMINI_3['name']}: {result}")
@pytest.mark.parametrize(
"provider_config",
ALL_PROVIDERS,
ids=get_provider_ids(ALL_PROVIDERS),
)
def test_mcp_tool(
self,
client: AIAgentTestClient,
setup_providers,
provider_config,
):
"""
Test that an AI agent can call an MCP tool (DeepWiki).
"""
tools = [
{
"id": "deepwiki",
"value": {
"tool_type": "mcp",
"resource_path": "$res:u/admin/deepwiki",
},
}
]
flow_value = create_ai_agent_flow(
provider_input_transform=provider_config["input_transform"],
system_prompt="You are a helpful assistant. Use the available tools to answer questions.",
tools=tools,
)
result = client.run_preview_flow(
flow_value=flow_value,
args={"user_message": "Use the read_wiki_structure tool to get the structure of the sveltejs/svelte repository."},
)
assert result is not None
print(f"MCP tool result from {provider_config['name']}: {result}")
@pytest.mark.parametrize(
"provider_config",
[OPENAI, ANTHROPIC, GOOGLE_AI],
ids=get_provider_ids([OPENAI, ANTHROPIC, GOOGLE_AI]),
)
def test_websearch_tool(
self,
client: AIAgentTestClient,
setup_providers,
provider_config,
):
"""
Test that an AI agent can use the websearch tool.
"""
tools = [
{
"id": "websearch",
"value": {
"tool_type": "websearch",
},
}
]
flow_value = create_ai_agent_flow(
provider_input_transform=provider_config["input_transform"],
system_prompt="You are a helpful assistant. Use websearch to find current information.",
tools=tools,
)
result = client.run_preview_flow(
flow_value=flow_value,
args={"user_message": "What is the current version of Svelte? Use websearch."},
)
assert result is not None
print(f"Websearch tool result from {provider_config['name']}: {result}")
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])