ReMe/tests/unit/test_cc_agent_wrapper.py
xyf2020 6b035c6553
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
Windows Smoke / CLI smoke - py3.11 (push) Waiting to run
feat(evaluation): track job calls and agent token usage in benchmarks (#406)
* feat(counter): extend counter tree utils and record job call statistics

- replace global_counter_next with fetch-and-add style global_counter_add/inc, plus read-only global_counter_get and global_counter_get_all
- record per-job call counts in app_context.metadata via BaseJob._record_call, covering background/cron/stream jobs
- update agentic_answer step and utils exports; add unit tests for job counting and counter utils

* feat(evaluation): add check_job_count interface and report search calls in benchmarks

- Extract _counter_key from BaseJob._record_call for reusable counter lookup
- Add reme.utils.evaluation_interface.check_job_count read-only helper
- Track and report average search calls per query in beam and longmemeval benchmarks

* job counter

* token消耗量统计

* benchmark输出完整token消耗统计

* benchmark统计输出改用标准差

- beam/longmemeval 的工具调用与 token 统计由方差改为标准差输出
- 修复 lint: 局部变量遮蔽 importlib.metadata、补充测试 docstring
- black 格式化

* fix(evaluation): preserve complete token usage metrics

* fix: exclude stream replies from token accounting

* Revert "fix: exclude stream replies from token accounting"

This reverts commit 85bf32064d.

* Reapply "fix: exclude stream replies from token accounting"

This reverts commit 6722c24dc5.

* support agent scope 2.0.5

* feat: support injection_config to disable runtime state injection in benchmarks

- Add InjectionConfig passthrough in AsAgentWrapper.reply()
- Disable inject_runtime_state in BaseAgenticAnswerStep to avoid
  wall-clock time conflicting with benchmark query_time anchors
- Disable inject_runtime_state in beam/lme llm_judge calls

* feat: agentscope dual-version compat & benchmark improvements

- Add version_tuple utility for semantic version comparison
- AsAgentWrapper: version-aware InjectionConfig, max_iters doubling,
  and token usage collection (reply vs reply_stream) for AS>=2.0.5/<2.0.5
- Default inject_runtime_state=False in wrapper to avoid benchmark
  time-anchor conflicts; remove per-callsite injection_config overrides
- longmemeval run.py: support question_ids filter in dataset config
- Fix unused import in test_evaluation_interface; format fixes

* chore: remove temporary flip-test benchmark config

* revert: pin agentscope to 2.0.4.post1 and drop dual-version compat

* fix(evaluation): clarify usage semantics and atomic counters

---------

Co-authored-by: sa-buc <jiangniurou.xyf@dail-algo011164204033.ET135>
Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
2026-08-04 11:42:18 +08:00

613 lines
22 KiB
Python

"""Tests for the Claude Code agent wrapper."""
from dataclasses import replace
from itertools import count
from pathlib import Path
from types import SimpleNamespace
import pytest
from reme.components.agent_wrapper.as_agent_wrapper import AsAgentWrapper
from reme.components.agent_wrapper.cc_agent_wrapper import CcAgentWrapper
from reme.components.agent_wrapper.cc_session_store import CcFileSessionStore
from reme.components.application_context import ApplicationContext
from reme.components.outbound_proxy import FixedHttpOutboundProxy
from reme.enumeration import ChunkEnum, ComponentEnum
# pylint: disable=protected-access
def _wrapper(tmp_path: Path) -> CcAgentWrapper:
return CcAgentWrapper(app_context=ApplicationContext(workspace_dir=str(tmp_path)))
def _skill_roots(tmp_path: Path) -> tuple[Path, Path]:
return (
tmp_path / ".claude" / "skills",
tmp_path / "mem_session" / "claude_config" / "skills",
)
def test_ensure_claude_skill_dir_adds_selected_skills_without_replacing_existing(
tmp_path,
):
"""Selected workspace skills are added while unrelated Claude skills remain."""
project_skills = tmp_path / "skills"
(project_skills / "one").mkdir(parents=True)
(project_skills / "two").mkdir()
for name in ("one", "two"):
(project_skills / name / "SKILL.md").write_text(f"# {name}", encoding="utf-8")
config_dir = tmp_path / "mem_session" / "claude_config"
for root in _skill_roots(tmp_path):
existing = root / "existing"
existing.mkdir(parents=True)
(existing / "SKILL.md").write_text("existing", encoding="utf-8")
_wrapper(tmp_path)._ensure_claude_skill_dir(config_dir, ["one"])
for root in _skill_roots(tmp_path):
assert (root / "one").is_symlink()
assert (root / "one").resolve() == (project_skills / "one").resolve()
assert not (root / "two").exists()
assert (root / "existing" / "SKILL.md").read_text(encoding="utf-8") == "existing"
def test_ensure_claude_skill_dir_all_adds_each_project_skill(tmp_path):
"""The all selector creates child links instead of replacing the skills root."""
project_skills = tmp_path / "skills"
(project_skills / "one").mkdir(parents=True)
(project_skills / "two").mkdir()
for name in ("one", "two"):
(project_skills / name / "SKILL.md").write_text(f"# {name}", encoding="utf-8")
config_dir = tmp_path / "mem_session" / "claude_config"
_wrapper(tmp_path)._ensure_claude_skill_dir(config_dir, "all")
for root in _skill_roots(tmp_path):
assert root.is_dir()
assert not root.is_symlink()
assert {path.name for path in root.iterdir()} == {"one", "two"}
def test_ensure_claude_skill_dir_preserves_existing_directory_link(tmp_path):
"""An existing skills link is user-owned and remains untouched."""
project_skills = tmp_path / "skills"
(project_skills / "one").mkdir(parents=True)
(project_skills / "one" / "SKILL.md").write_text("# one", encoding="utf-8")
config_dir = tmp_path / "mem_session" / "claude_config"
legacy_root = tmp_path / ".claude" / "skills"
legacy_root.parent.mkdir(parents=True)
legacy_root.symlink_to(project_skills, target_is_directory=True)
_wrapper(tmp_path)._ensure_claude_skill_dir(config_dir, ["one"])
assert legacy_root.is_dir()
assert legacy_root.is_symlink()
assert (legacy_root / "one").resolve() == (project_skills / "one").resolve()
@pytest.mark.asyncio
async def test_file_session_store_conforms_to_latest_sdk(tmp_path):
"""The file store follows the SDK project/session/subkey contract."""
from claude_agent_sdk.testing import run_session_store_conformance
sequence = count()
await run_session_store_conformance(lambda: CcFileSessionStore(tmp_path / str(next(sequence))))
def test_ensure_claude_skill_dir_rejects_paths_as_skill_names(tmp_path):
"""Skill selectors cannot escape the project skills directory."""
(tmp_path / "skills").mkdir()
with pytest.raises(ValueError, match="Invalid skill name"):
_wrapper(tmp_path)._ensure_claude_skill_dir(tmp_path / "config", ["../outside"])
def test_configured_skills_use_latest_sdk_allowlist(tmp_path):
"""Selected ReMe skills are passed through using the SDK's list semantics."""
project_skills = tmp_path / "skills"
(project_skills / "one").mkdir(parents=True)
(project_skills / "two").mkdir()
for name in ("one", "two"):
(project_skills / name / "SKILL.md").write_text(f"# {name}", encoding="utf-8")
opts = _wrapper(tmp_path)._build_options("hello", skills=["one"])
assert opts.skills == ["one"]
for root in _skill_roots(tmp_path):
assert (root / "one").resolve() == (project_skills / "one").resolve()
assert not (root / "two").exists()
def test_configured_project_path_sources_skills_outside_workspace(tmp_path):
"""Claude Code links project skills while keeping sessions in the workspace."""
project = tmp_path / "project"
workspace = project / ".reme"
skill = project / "skills" / "serper-search"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text("# Serper Search", encoding="utf-8")
wrapper = CcAgentWrapper(
app_context=ApplicationContext(workspace_dir=str(workspace)),
project_path="..",
)
opts = wrapper._build_options("hello", skills=["serper-search"])
assert opts.cwd == project
assert (project / ".claude" / "skills" / "serper-search").resolve() == skill
assert (workspace / "mem_session" / "claude_config" / "skills" / "serper-search").resolve() == skill
def test_sdk_native_system_prompt_preset_is_preserved(tmp_path):
"""System prompt dictionaries pass directly to the latest SDK."""
opts = _wrapper(tmp_path)._build_options(
"hello",
system_prompt={
"type": "preset",
"preset": "claude_code",
"append": "custom prompt",
},
)
assert opts.system_prompt == {
"type": "preset",
"preset": "claude_code",
"append": "custom prompt",
}
def test_web_search_is_disallowed_by_default(tmp_path):
"""Claude Code keeps its normal tools except for web search."""
opts = _wrapper(tmp_path)._build_options("hello")
assert opts.disallowed_tools == ["WebSearch"]
def test_api_credentials_use_only_wrapper_config(tmp_path, monkeypatch):
"""Claude Code credentials do not fall back to ambient or shared LLM configuration."""
wrapper = _wrapper(tmp_path)
wrapper.app_context.app_config.environment = {
"ANTHROPIC_AUTH_TOKEN": "application-key",
"ANTHROPIC_BASE_URL": "https://application.example.test",
"TOOL_ENV": "preserved",
}
wrapper.app_context.app_config.components[ComponentEnum.AS_LLM] = {
"default": SimpleNamespace(
credential={
"api_key": "default-key",
"base_url": "https://default.example.test",
},
),
}
for name in ("ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_API_KEY", "LLM_API_KEY"):
monkeypatch.setenv(name, "ambient-key")
for name in ("ANTHROPIC_BASE_URL", "CLAUDE_CODE_BASE_URL", "LLM_BASE_URL"):
monkeypatch.setenv(name, "https://ambient.example.test")
configured = wrapper._build_options( # pylint: disable=protected-access
"hello",
api_key="configured-key",
base_url="https://configured.example.test",
credential={"api_key": "nested-key", "base_url": "https://nested.example.test"},
)
assert configured.env["ANTHROPIC_AUTH_TOKEN"] == "configured-key"
assert configured.env["ANTHROPIC_BASE_URL"] == "https://configured.example.test"
assert configured.env["TOOL_ENV"] == "preserved"
empty = wrapper._build_options( # pylint: disable=protected-access
"hello",
credential={"api_key": "nested-key", "base_url": "https://nested.example.test"},
)
assert empty.env["ANTHROPIC_AUTH_TOKEN"] == ""
assert empty.env["ANTHROPIC_BASE_URL"] == ""
@pytest.mark.asyncio
async def test_managed_proxy_is_injected_only_into_claude_bash_commands(tmp_path):
"""The Claude CLI keeps its model environment while Bash receives proxy exports."""
from claude_agent_sdk import HookMatcher
wrapper = _wrapper(tmp_path)
proxy = FixedHttpOutboundProxy(url="http://127.0.0.1:18080")
await proxy.start()
wrapper.app_context.components = {ComponentEnum.OUTBOUND_PROXY: {"default": proxy}}
async def existing_hook(_hook_input, _tool_use_id, _context):
return {}
existing = HookMatcher(matcher="Read", hooks=[existing_hook])
await wrapper.start()
opts = wrapper._build_options(
"hello",
api_key="configured-key",
base_url="https://configured.example.test",
hooks={"PreToolUse": [existing]},
)
assert opts.env["ANTHROPIC_AUTH_TOKEN"] == "configured-key"
assert opts.env["ANTHROPIC_BASE_URL"] == "https://configured.example.test"
assert "HTTP_PROXY" not in opts.env
assert opts.hooks["PreToolUse"][0] is existing
proxy_hook = opts.hooks["PreToolUse"][1]
assert proxy_hook.matcher == "Bash"
result = await proxy_hook.hooks[0](
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "python tushare_analysis.py", "timeout": 30},
"tool_use_id": "tool-1",
},
"tool-1",
{"signal": None},
)
updated_input = result["hookSpecificOutput"]["updatedInput"]
assert updated_input["command"].endswith("; python tushare_analysis.py")
assert updated_input["timeout"] == 30
for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"):
assert f"{key}={proxy.http_url}" in updated_input["command"]
await wrapper.close()
await proxy.close()
def test_build_options_accepts_empty_output_schema(tmp_path):
"""An empty schema remains a valid structured-output request."""
opts = _wrapper(tmp_path)._build_options("hello", output_schema={})
assert opts.output_format == {"type": "json_schema", "schema": {}}
def test_build_options_uses_native_sessions_and_allows_file_checkpointing(tmp_path):
"""Local Claude transcripts remain the default and do not conflict with checkpoints."""
opts = _wrapper(tmp_path)._build_options("hello", enable_file_checkpointing=True)
assert opts.enable_file_checkpointing is True
assert opts.session_store is None
assert opts.env["CLAUDE_CONFIG_DIR"] == str(tmp_path / "mem_session" / "claude_config")
def test_build_options_preserves_explicit_session_store(tmp_path):
"""Callers can still opt into the SDK's external transcript mirror."""
store = CcFileSessionStore(tmp_path / "mirror")
opts = _wrapper(tmp_path)._build_options("hello", session_store=store)
assert opts.session_store is store
def test_job_tools_reject_non_mapping_mcp_config_instead_of_discarding_it(tmp_path, monkeypatch):
"""Adding ReMe tools never silently replaces an SDK MCP config path."""
wrapper = _wrapper(tmp_path)
job = SimpleNamespace(name="remember", description="Remember", parameters={})
monkeypatch.setattr(wrapper, "_resolve_job_tools", lambda _names: [job])
with pytest.raises(ValueError, match="mcp_servers to be a mapping"):
wrapper._build_options("hello", job_tools=["remember"], mcp_servers=tmp_path / "mcp.json")
def test_job_tools_merge_with_mapping_mcp_config(tmp_path, monkeypatch):
"""Existing MCP configuration remains reusable beside ReMe tools."""
wrapper = _wrapper(tmp_path)
job = SimpleNamespace(name="remember", description="Remember", parameters={})
monkeypatch.setattr(wrapper, "_resolve_job_tools", lambda _names: [job])
external = {"type": "http", "url": "https://mcp.example.test"}
mcp_servers = {"external": external}
allowed_tools = ["Read"]
first = wrapper._build_options(
"hello",
job_tools=["remember"],
mcp_servers=mcp_servers,
allowed_tools=allowed_tools,
)
second = wrapper._build_options(
"hello",
job_tools=["remember"],
mcp_servers=mcp_servers,
allowed_tools=allowed_tools,
)
assert mcp_servers == {"external": external}
assert allowed_tools == ["Read"]
for opts in (first, second):
assert opts.mcp_servers["external"] is external
assert opts.mcp_servers[wrapper.MCP_SERVER_NAME]["type"] == "sdk"
assert opts.allowed_tools == ["Read", "remember"]
@pytest.mark.asyncio
async def test_reply_preserves_falsy_structured_output(tmp_path, monkeypatch):
"""Falsy structured output is returned instead of being discarded."""
from claude_agent_sdk import ResultMessage
message = ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id="session-1",
result="{}",
structured_output={"placeholder": True},
)
async def query(**_kwargs):
yield replace(message, structured_output={})
monkeypatch.setattr("claude_agent_sdk.query", query)
result = await _wrapper(tmp_path).reply("hello", output_schema={})
assert "structured_output" in result
assert result["structured_output"] == {}
@pytest.mark.asyncio
async def test_compact_session_uses_claude_command(tmp_path, monkeypatch):
"""Claude Code compaction uses its native slash command."""
wrapper = _wrapper(tmp_path)
calls = []
async def reply(inputs, **kwargs):
calls.append((inputs, kwargs))
return {"last_message": {"is_error": False}}
monkeypatch.setattr(wrapper, "reply", reply)
await wrapper.compact_session("session-1")
assert calls == [("/compact", {"resume": "session-1"})]
@pytest.mark.asyncio
async def test_agentscope_compact_session_forces_and_persists_compression(tmp_path, monkeypatch):
"""AgentScope compaction forces the threshold and persists new state."""
wrapper = AsAgentWrapper(as_llm="", app_context=ApplicationContext(workspace_dir=str(tmp_path)))
observed = {}
class FakeAgent:
"""Minimal AgentScope agent double."""
state = SimpleNamespace()
async def compress_context(self, config):
"""Capture the forced context configuration."""
observed["config"] = config
async def build_agent(inputs, **kwargs):
observed["build"] = (inputs, kwargs)
return FakeAgent(), inputs
async def dump_state(state):
observed["state"] = state
monkeypatch.setattr(wrapper, "_build_agent", build_agent)
monkeypatch.setattr(wrapper, "_dump_state", dump_state)
await wrapper.compact_session("session-1")
assert observed["build"] == (None, {"resume": "session-1"})
assert observed["config"].trigger_ratio == 1e-9
assert observed["state"] is FakeAgent.state
def test_error_result_with_success_subtype_is_not_suppressed():
"""Latest SDK can report API failures with subtype=success and is_error=True."""
from claude_agent_sdk import ResultMessage
message = ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=True,
num_turns=1,
session_id="session-1",
errors=["upstream unavailable"],
api_error_status=529,
)
chunks = CcAgentWrapper._result_message_to_chunks(message)
error = next(chunk for chunk in chunks if chunk.chunk_type.value == "error")
assert error.chunk == "upstream unavailable"
assert error.metadata == {"api_error_status": 529}
def test_latest_sdk_server_tool_blocks_are_converted():
"""Server-side tools use the same unified call/result lifecycle."""
from claude_agent_sdk import AssistantMessage, ServerToolResultBlock
call = CcAgentWrapper._raw_event_to_chunk(
{
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "server_tool_use",
"id": "tool-1",
"name": "web_search",
},
},
)
message = AssistantMessage(
content=[ServerToolResultBlock(tool_use_id="tool-1", content={"type": "web_search_result"})],
model="claude",
)
results = CcAgentWrapper._message_content_to_chunks(message, visible_tool_call_ids={"tool-1"})
assert call is not None and call.chunk_type.value == "tool_call"
assert len(results) == 1
assert results[0].chunk_type.value == "tool_result"
assert results[0].chunk == {
"tool_use_id": "tool-1",
"content": {"type": "web_search_result"},
}
@pytest.mark.asyncio
async def test_reply_stream_emits_one_reply_end_for_normal_sdk_lifecycle(tmp_path, monkeypatch):
"""Message delta reports usage while message stop is the sole lifecycle end."""
from claude_agent_sdk import ResultMessage, StreamEvent
async def query(**_kwargs):
for event in (
{
"type": "message_start",
"message": {"id": "message-1", "role": "assistant"},
},
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn"},
"usage": {"output_tokens": 2},
},
{"type": "message_stop"},
):
yield StreamEvent(uuid="event-1", session_id="session-1", event=event)
yield ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id="session-1",
usage={"input_tokens": 1, "output_tokens": 2},
)
wrapper = _wrapper(tmp_path)
recorded_usages = []
monkeypatch.setattr("claude_agent_sdk.query", query)
monkeypatch.setattr(wrapper, "_record_token_usage", recorded_usages.append)
chunks = [chunk async for chunk in wrapper.reply_stream("hello")]
assert sum(chunk.chunk_type == ChunkEnum.REPLY_END for chunk in chunks) == 1
delta_usage = next(chunk for chunk in chunks if chunk.metadata.get("stop_reason") == "end_turn")
assert delta_usage.chunk_type == ChunkEnum.USAGE
assert delta_usage.output_tokens == 2
assert not recorded_usages
@pytest.mark.asyncio
async def test_reply_stream_only_reports_rejected_rate_limit(tmp_path, monkeypatch):
"""Rate-limit warnings are informational; only rejected is an error."""
from claude_agent_sdk import RateLimitEvent, RateLimitInfo, ResultMessage
async def query(**_kwargs):
yield RateLimitEvent(
rate_limit_info=RateLimitInfo(status="allowed_warning"),
uuid="warning",
session_id="session-1",
)
yield RateLimitEvent(
rate_limit_info=RateLimitInfo(status="rejected"),
uuid="rejected",
session_id="session-1",
)
yield ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id="session-1",
)
monkeypatch.setattr("claude_agent_sdk.query", query)
chunks = [chunk async for chunk in _wrapper(tmp_path).reply_stream("hello")]
errors = [chunk for chunk in chunks if chunk.chunk_type.value == "error"]
assert [chunk.chunk for chunk in errors] == ["Rate limit exceeded"]
@pytest.mark.asyncio
async def test_reply_stream_uses_error_result_as_terminal_error(tmp_path, monkeypatch):
"""The SDK's non-zero process exit does not duplicate an emitted error result."""
from claude_agent_sdk import ResultMessage
async def query(**_kwargs):
yield ResultMessage(
subtype="error_during_execution",
duration_ms=1,
duration_api_ms=1,
is_error=True,
num_turns=1,
session_id="session-1",
errors=["tool failed"],
)
raise RuntimeError("Claude Code returned an error result: tool failed")
monkeypatch.setattr("claude_agent_sdk.query", query)
chunks = [chunk async for chunk in _wrapper(tmp_path).reply_stream("hello")]
errors = [chunk for chunk in chunks if chunk.chunk_type.value == "error"]
assert [chunk.chunk for chunk in errors] == ["tool failed"]
@pytest.mark.asyncio
async def test_reply_stream_does_not_hide_unrelated_error_after_error_result(tmp_path, monkeypatch):
"""Only the SDK's exact trailing process error is suppressed."""
from claude_agent_sdk import ResultMessage
async def query(**_kwargs):
yield ResultMessage(
subtype="error_during_execution",
duration_ms=1,
duration_api_ms=1,
is_error=True,
num_turns=1,
session_id="session-1",
errors=["tool failed"],
)
raise RuntimeError("unrelated store failure")
monkeypatch.setattr("claude_agent_sdk.query", query)
with pytest.raises(RuntimeError, match="unrelated store failure"):
_ = [chunk async for chunk in _wrapper(tmp_path).reply_stream("hello")]
@pytest.mark.asyncio
async def test_reply_stream_reports_session_mirror_errors(tmp_path, monkeypatch):
"""The latest SDK's non-fatal mirror failures remain visible to callers."""
from claude_agent_sdk import MirrorErrorMessage, ResultMessage
async def query(**_kwargs):
yield MirrorErrorMessage(
subtype="mirror_error",
data={},
key={"project_key": "project", "session_id": "session-1"},
error="disk full",
)
yield ResultMessage(
subtype="success",
duration_ms=1,
duration_api_ms=1,
is_error=False,
num_turns=1,
session_id="session-1",
)
monkeypatch.setattr("claude_agent_sdk.query", query)
chunks = [chunk async for chunk in _wrapper(tmp_path).reply_stream("hello")]
diagnostics = [chunk for chunk in chunks if chunk.metadata.get("event") == "session_mirror_error"]
assert len(diagnostics) == 1
assert diagnostics[0].chunk_type == ChunkEnum.DATA
assert diagnostics[0].chunk == "Session mirror failed: disk full"
assert not any(chunk.chunk_type == ChunkEnum.ERROR for chunk in chunks)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"wrapper_factory",
[
_wrapper,
lambda tmp_path: AsAgentWrapper(as_llm="", app_context=ApplicationContext(workspace_dir=str(tmp_path))),
],
)
@pytest.mark.parametrize("schema", [{}, {"type": "object"}])
async def test_reply_stream_rejects_output_schema(tmp_path, wrapper_factory, schema):
"""Streaming wrappers reject structured-output schemas consistently."""
wrapper = wrapper_factory(tmp_path)
with pytest.raises(NotImplementedError, match="Structured output is not supported"):
await anext(wrapper.reply_stream("hello", output_schema=schema))