feat: 优化 Agent 运行时与聊天设置体验
This commit is contained in:
@@ -9,6 +9,7 @@ from core.agentscope.runtime.runner import (
|
||||
StageExecutionResult,
|
||||
SystemAgentRuntimeConfig,
|
||||
)
|
||||
from core.agentscope.utils import safe_json_loads_with_repair
|
||||
from schemas.agent.runtime_models import (
|
||||
RouterAgentOutput,
|
||||
UiMode,
|
||||
@@ -54,18 +55,7 @@ def _run_input() -> RunAgentInput:
|
||||
"runId": "run-1",
|
||||
"state": {},
|
||||
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
|
||||
"tools": [
|
||||
{
|
||||
"name": "calendar.read",
|
||||
"description": "read",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
{
|
||||
"name": "calendar-write",
|
||||
"description": "write",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {},
|
||||
}
|
||||
@@ -94,6 +84,30 @@ def _router_output(*, ui_mode: UiMode) -> RouterAgentOutput:
|
||||
)
|
||||
|
||||
|
||||
def test_build_worker_input_messages_includes_field_guide() -> None:
|
||||
runner = AgentScopeRunner()
|
||||
|
||||
messages = runner._build_worker_input_messages(
|
||||
router_output=_router_output(ui_mode=UiMode.NONE)
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
content = str(messages[0].content)
|
||||
assert "[Worker Contract]" in content
|
||||
assert "Use normalized_task_input as objective text." in content
|
||||
assert "multimodal_summary/key_entities/constraints" in content
|
||||
assert "key_entities" in content
|
||||
assert "constraints" in content
|
||||
assert "Infer deterministic missing required tool args" in content
|
||||
|
||||
|
||||
def test_safe_json_loads_with_repair_parses_valid_json() -> None:
|
||||
parsed = safe_json_loads_with_repair('{"operation":"create","title":"test"}')
|
||||
|
||||
assert parsed["operation"] == "create"
|
||||
assert parsed["title"] == "test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -110,11 +124,6 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
|
||||
"core.agentscope.runtime.runner.AsyncSessionLocal",
|
||||
lambda: _FakeSessionCtx(_CommitSession()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_build_toolkits",
|
||||
lambda **kwargs: ("router-toolkit", "worker-toolkit"),
|
||||
)
|
||||
|
||||
async def _load_system_agent_config(**kwargs):
|
||||
return SystemAgentRuntimeConfig(
|
||||
@@ -147,7 +156,10 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
|
||||
async def _persist_router_message(**kwargs) -> None:
|
||||
assert kwargs["model_code"] == "qwen3.5-flash"
|
||||
|
||||
monkeypatch.setattr(runner, "_persist_router_message", _persist_router_message)
|
||||
monkeypatch.setattr(
|
||||
"core.agentscope.runtime.runner.persist_router_message",
|
||||
_persist_router_message,
|
||||
)
|
||||
|
||||
async def _run_worker_stage(**kwargs):
|
||||
worker_model_holder["model"] = kwargs["worker_output_model"]
|
||||
@@ -196,11 +208,3 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
|
||||
]
|
||||
assert result["router"]["ui"]["ui_mode"] == "rich"
|
||||
assert result["worker"]["answer"] == "done"
|
||||
|
||||
|
||||
def test_extract_tool_names_normalizes_client_tool_names() -> None:
|
||||
runner = AgentScopeRunner()
|
||||
|
||||
names = runner._extract_tool_names(_run_input())
|
||||
|
||||
assert names == {"calendar_read", "calendar_write"}
|
||||
|
||||
@@ -174,3 +174,60 @@ async def test_run_agentscope_task_rejects_invalid_command_type() -> None:
|
||||
"run_input": _run_input_payload(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_recent_context_messages_includes_all_user_attachments(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FakeAgentService:
|
||||
async def load_agent_input_messages(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
) -> dict[str, object] | None:
|
||||
del thread_id
|
||||
return {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "请看图片",
|
||||
"metadata": {
|
||||
"user_message_attachments": [
|
||||
{
|
||||
"bucket": "bucket-1",
|
||||
"path": "path/a.png",
|
||||
"mime_type": "image/png",
|
||||
},
|
||||
{
|
||||
"bucket": "bucket-1",
|
||||
"path": "path/b.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
class _FakeSupabase:
|
||||
async def download_bytes(self, *, bucket: str, path: str) -> bytes:
|
||||
return f"{bucket}:{path}".encode("utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
tasks_module, "get_agent_service", lambda session: _FakeAgentService()
|
||||
)
|
||||
monkeypatch.setattr(tasks_module, "supabase_service", _FakeSupabase())
|
||||
|
||||
messages = await tasks_module._build_recent_context_messages(
|
||||
session=object(),
|
||||
thread_id=str(uuid4()),
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
content = messages[0].content
|
||||
assert isinstance(content, list)
|
||||
assert len(content) == 3
|
||||
assert content[0]["type"] == "text"
|
||||
assert content[1]["type"] == "image"
|
||||
assert content[2]["type"] == "image"
|
||||
|
||||
@@ -5,7 +5,9 @@ from core.agentscope.prompts.agent_prompt import (
|
||||
WORKER_AGENT_INSTRUCTION,
|
||||
build_agent_prompt,
|
||||
build_intent_user_prompt,
|
||||
build_worker_contract_prompt,
|
||||
)
|
||||
from schemas.agent.runtime_models import RouterAgentOutput
|
||||
from schemas.agent.system_agent import AgentType
|
||||
|
||||
|
||||
@@ -39,6 +41,9 @@ def test_build_agent_prompt_for_worker_relies_on_injected_schema() -> None:
|
||||
assert "- type: worker" in prompt
|
||||
assert WORKER_AGENT_INSTRUCTION in prompt
|
||||
assert "execute routed objective" in prompt
|
||||
assert "objective/constraints contract" in prompt
|
||||
assert "Infer deterministic required tool arguments" in prompt
|
||||
assert "Ask minimal clarification" in prompt
|
||||
assert "never fabricate execution state" in prompt
|
||||
assert (
|
||||
"The worker output schema is injected at runtime; follow it exactly." in prompt
|
||||
@@ -46,3 +51,35 @@ def test_build_agent_prompt_for_worker_relies_on_injected_schema() -> None:
|
||||
assert "Do not add fields that are not present in the injected schema." in prompt
|
||||
assert "ui_mode=rich" not in prompt
|
||||
assert "ui_mode=none" not in prompt
|
||||
|
||||
|
||||
def test_build_worker_contract_prompt_is_concise_and_actionable() -> None:
|
||||
router_output = RouterAgentOutput.model_validate(
|
||||
{
|
||||
"normalized_task_input": {
|
||||
"user_text": "创建明天 9 点会议",
|
||||
"multimodal_summary": ["图片里有会议时间"],
|
||||
},
|
||||
"key_entities": [
|
||||
{
|
||||
"name": "start",
|
||||
"type": "datetime",
|
||||
"value": "2026-03-17T09:00:00+08:00",
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{"key": "timezone", "value": "Asia/Shanghai", "required": True}
|
||||
],
|
||||
"task_typing": {"primary": "scheduling", "secondary": []},
|
||||
"execution_mode": "tool_assisted",
|
||||
"result_typing": {"primary": "execution_report", "secondary": []},
|
||||
"ui": {"ui_mode": "none", "ui_decision_reason": "plain"},
|
||||
}
|
||||
)
|
||||
|
||||
prompt = build_worker_contract_prompt(router_output=router_output)
|
||||
|
||||
assert "[Worker Contract]" in prompt
|
||||
assert "Infer deterministic missing required tool args" in prompt
|
||||
assert "[RouterAgentOutput]" in prompt
|
||||
assert '"execution_mode":"tool_assisted"' in prompt
|
||||
|
||||
@@ -126,7 +126,10 @@ def _user() -> CurrentUser:
|
||||
)
|
||||
|
||||
|
||||
def _build_run_input(*, url: str) -> RunAgentInput:
|
||||
def _build_run_input(*, urls: list[str]) -> RunAgentInput:
|
||||
content: list[dict[str, str]] = [{"type": "text", "text": "hello"}]
|
||||
for url in urls:
|
||||
content.append({"type": "binary", "mimeType": "image/png", "url": url})
|
||||
return RunAgentInput.model_validate(
|
||||
{
|
||||
"threadId": "00000000-0000-0000-0000-000000000001",
|
||||
@@ -136,10 +139,7 @@ def _build_run_input(*, url: str) -> RunAgentInput:
|
||||
{
|
||||
"id": "u1",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hello"},
|
||||
{"type": "binary", "mimeType": "image/png", "url": url},
|
||||
],
|
||||
"content": content,
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
@@ -161,7 +161,9 @@ async def test_enqueue_run_rejects_non_project_host_signed_url(monkeypatch) -> N
|
||||
attachment_storage=_FakeAttachmentStorage(),
|
||||
)
|
||||
run_input = _build_run_input(
|
||||
url="https://evil.example.com/storage/v1/object/sign/agent-test-bucket/a.png?token=1"
|
||||
urls=[
|
||||
"https://evil.example.com/storage/v1/object/sign/agent-test-bucket/a.png?token=1"
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -191,8 +193,15 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token(
|
||||
"agent-inputs/00000000-0000-0000-0000-000000000001/"
|
||||
"00000000-0000-0000-0000-000000000001/uploads/a.png"
|
||||
)
|
||||
safe_path_two = quote(
|
||||
"agent-inputs/00000000-0000-0000-0000-000000000001/"
|
||||
"00000000-0000-0000-0000-000000000001/uploads/b.png"
|
||||
)
|
||||
run_input = _build_run_input(
|
||||
url=f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1"
|
||||
urls=[
|
||||
f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1",
|
||||
f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path_two}?token=1",
|
||||
]
|
||||
)
|
||||
|
||||
accepted = await service.enqueue_run(run_input=run_input, current_user=_user())
|
||||
@@ -201,9 +210,10 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token(
|
||||
persisted = repository.persisted_user_messages[0]
|
||||
metadata = cast(AgentChatMessageMetadata | None, persisted["metadata"])
|
||||
assert metadata is not None
|
||||
attachment = metadata.user_message_attachments
|
||||
assert attachment is not None
|
||||
assert attachment.bucket == "agent-test-bucket"
|
||||
attachments = metadata.user_message_attachments
|
||||
assert attachments is not None
|
||||
assert len(attachments) == 2
|
||||
assert attachments[0].bucket == "agent-test-bucket"
|
||||
command = queue.commands[0]
|
||||
assert "user_token" not in command
|
||||
run_input = command["run_input"]
|
||||
@@ -257,3 +267,47 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path(
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_service_module.config.storage, "bucket", "agent-test-bucket"
|
||||
)
|
||||
service = AgentService(
|
||||
repository=_FakeRepository(),
|
||||
queue=_FakeQueue(),
|
||||
stream=_FakeStream(),
|
||||
attachment_storage=_FakeAttachmentStorage(),
|
||||
)
|
||||
base_url = str(config.supabase.url).rstrip("/")
|
||||
safe_paths = [
|
||||
quote(
|
||||
"agent-inputs/00000000-0000-0000-0000-000000000001/"
|
||||
"00000000-0000-0000-0000-000000000001/uploads/a.png"
|
||||
),
|
||||
quote(
|
||||
"agent-inputs/00000000-0000-0000-0000-000000000001/"
|
||||
"00000000-0000-0000-0000-000000000001/uploads/b.png"
|
||||
),
|
||||
quote(
|
||||
"agent-inputs/00000000-0000-0000-0000-000000000001/"
|
||||
"00000000-0000-0000-0000-000000000001/uploads/c.png"
|
||||
),
|
||||
quote(
|
||||
"agent-inputs/00000000-0000-0000-0000-000000000001/"
|
||||
"00000000-0000-0000-0000-000000000001/uploads/d.png"
|
||||
),
|
||||
]
|
||||
run_input = _build_run_input(
|
||||
urls=[
|
||||
f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1"
|
||||
for safe_path in safe_paths
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await service.enqueue_run(run_input=run_input, current_user=_user())
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail == "Too many attachments"
|
||||
|
||||
@@ -48,3 +48,26 @@ def test_convert_message_to_history_uses_ui_schema_key_for_assistant_message() -
|
||||
assert "ui_schema" in result
|
||||
assert "uiSchema" not in result
|
||||
assert result["ui_schema"] == {"version": "2.0", "root": {"type": "stack"}}
|
||||
|
||||
|
||||
def test_convert_message_to_history_returns_multiple_user_attachments() -> None:
|
||||
message = _FakeMessage(
|
||||
role="user",
|
||||
metadata={
|
||||
"user_message_attachments": [
|
||||
{"bucket": "bucket-a", "path": "path/a.png", "mime_type": "image/png"},
|
||||
{"bucket": "bucket-a", "path": "path/b.jpg", "mime_type": "image/jpeg"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def _signed(payload: dict[str, str]) -> str:
|
||||
return f"https://signed.example/{payload['bucket']}/{payload['path']}"
|
||||
|
||||
result = convert_message_to_history(message, _signed) # type: ignore[arg-type]
|
||||
|
||||
attachments = result.get("attachments")
|
||||
assert isinstance(attachments, list)
|
||||
assert len(attachments) == 2
|
||||
assert attachments[0]["mimeType"] == "image/png"
|
||||
assert attachments[1]["mimeType"] == "image/jpeg"
|
||||
|
||||
Reference in New Issue
Block a user