Skip to content

Commit 3701990

Browse files
fix(chat): resolve native targets through published capability bindings
1 parent 1db20f8 commit 3701990

6 files changed

Lines changed: 261 additions & 38 deletions

File tree

‎backend/agent_runtimes/claude_code_adapter.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
_VERSION_TIMEOUT_SECONDS = 5
3939
_KILL_GRACE_SECONDS = 10
4040
_STDERR_TAIL_BYTES = 2048
41+
_CAPABILITY_ID = "runtime.claude-code"
4142
_PERMISSION_MODES = frozenset({"observe_only", "suggest_changes", "approval_required", "full_auto"})
4243
_CLAUDE_PERMISSION_MODES = {
4344
"observe_only": "plan",
@@ -116,6 +117,7 @@ async def readiness(self, config: dict[str, Any] | None = None) -> RuntimeReadin
116117
if errors:
117118
return RuntimeReadiness(
118119
runtime=self.runtime_type,
120+
capability_id=_CAPABILITY_ID,
119121
status="blocked",
120122
binary_present=False,
121123
reason_code="invalid_config",
@@ -127,6 +129,7 @@ async def readiness(self, config: dict[str, Any] | None = None) -> RuntimeReadin
127129
if resolved_binary is None:
128130
return RuntimeReadiness(
129131
runtime=self.runtime_type,
132+
capability_id=_CAPABILITY_ID,
130133
status="blocked",
131134
binary_present=False,
132135
reason_code="missing_binary",
@@ -137,6 +140,7 @@ async def readiness(self, config: dict[str, Any] | None = None) -> RuntimeReadin
137140
except ValueError as exc:
138141
return RuntimeReadiness(
139142
runtime=self.runtime_type,
143+
capability_id=_CAPABILITY_ID,
140144
status="blocked",
141145
binary_present=True,
142146
permitted_project_root=self._display_path(config.get("project_root")),
@@ -145,8 +149,20 @@ async def readiness(self, config: dict[str, Any] | None = None) -> RuntimeReadin
145149
reason=str(exc),
146150
)
147151
version = await self._detect_version(resolved_binary, config.get("args") or [])
152+
if version is None:
153+
return RuntimeReadiness(
154+
runtime=self.runtime_type,
155+
capability_id=_CAPABILITY_ID,
156+
status="blocked",
157+
binary_present=True,
158+
permitted_project_root=str(project_root),
159+
working_directory=str(cwd),
160+
reason_code="version_probe_failed",
161+
reason="Claude Code binary did not complete a compatible version probe",
162+
)
148163
return RuntimeReadiness(
149164
runtime=self.runtime_type,
165+
capability_id=_CAPABILITY_ID,
150166
status="ready",
151167
binary_present=True,
152168
version=version,
@@ -424,6 +440,8 @@ async def _detect_version(
424440
proc.kill()
425441
await proc.wait()
426442
raise
443+
if proc.returncode != 0:
444+
return None
427445
text = stdout.decode(errors="replace") if stdout else ""
428446
match = _VERSION_RE.search(text)
429447
return match.group(1) if match else None

‎backend/services/agent_conversation_service.py‎

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Any, Protocol
1212

1313
from fastapi import HTTPException, status
14+
from pydantic import ValidationError
1415
from sqlalchemy import func, select
1516
from sqlalchemy.exc import IntegrityError
1617
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
@@ -26,7 +27,6 @@
2627
AgentConversationTurnStatus,
2728
)
2829
from backend.models.agent_run import AgentRun, AgentSession
29-
from backend.models.edge_node import EdgeNode
3030
from backend.models.identity import Workspace as GovernedWorkspace
3131
from backend.models.operations_agent import (
3232
OperationsAgentIdentity,
@@ -39,13 +39,17 @@
3939
from backend.models.workflow import Project as GovernedProject
4040
from backend.models.workflow import Workflow as GovernedWorkflow
4141
from backend.models.workflow_run import WorkflowRun
42-
from backend.schemas.operations_agent import agent_runtime_binding_from_model_configuration
42+
from backend.schemas.operations_agent import (
43+
agent_contract_from_model_configuration,
44+
agent_runtime_binding_from_model_configuration,
45+
)
4346
from backend.security.identity import RequestIdentity
4447
from backend.security.workspace_rbac import (
4548
WorkspacePermission,
4649
require_permission,
4750
)
4851
from backend.services import agent_conversation_run_service as run_service
52+
from backend.services.agent_runtime_selection import RuntimeSelectionError, select_agent_runtime
4953
from backend.services.studio_agent_session_access import (
5054
AgentSessionWorkspaceScope,
5155
resolve_agent_session_workspace,
@@ -431,8 +435,6 @@ async def list_execution_targets(
431435
}
432436
)
433437

434-
from backend import ws_agent_manager
435-
436438
agents = list(
437439
await db.scalars(
438440
select(OperationsAgentIdentity)
@@ -457,52 +459,52 @@ async def list_execution_targets(
457459
runtime_binding = agent_runtime_binding_from_model_configuration(
458460
version.model_configuration
459461
)
460-
except Exception:
461-
runtime_binding = None
462-
if runtime_binding is None:
463-
continue
464-
node = await db.scalar(select(EdgeNode).where(EdgeNode.url == runtime_binding.agent_url))
465-
advertised = (
466-
(node.runtime_capabilities or {}).get(runtime_binding.runtime, []) if node else []
467-
)
468-
capabilities = sorted({item for item in advertised if isinstance(item, str) and item})
469-
node_connected = bool(
470-
node
471-
and node.status == "online"
472-
and node.protocol == "ws"
473-
and ws_agent_manager.is_connected(node.url)
474-
)
475-
reason_code = (
476-
"native_session_contract_unavailable" if node_connected else "runtime_node_unavailable"
477-
)
462+
contract = agent_contract_from_model_configuration(version.model_configuration)
463+
except (ValidationError, AttributeError, TypeError):
464+
runtime_binding, contract = None, None
465+
selection = None
466+
reason_code = "runtime_binding_invalid"
467+
reason = "请在 Agent 配置中补全并发布执行配置。"
468+
setup_url = "/operations-agents"
469+
if runtime_binding is not None and contract is not None:
470+
# V2 describes selection preferences, not a fixed agent_url/runtime.
471+
# Use the same capability selector as dispatch; a preference is not
472+
# proof that a matching node is connected or supports the contract.
473+
try:
474+
selection = await select_agent_runtime(
475+
db, contract=contract, binding=runtime_binding
476+
)
477+
except RuntimeSelectionError:
478+
reason_code = "runtime_node_unavailable"
479+
reason = "没有满足此 Agent 能力要求的在线运行节点,请检查节点连接与已安装能力。"
480+
setup_url = "/nodes"
481+
else:
482+
reason_code = "native_session_contract_unavailable"
483+
reason = "此页面的原生会话接入尚未完成;目前可配置 API 模型开始会话。"
484+
setup_url = "/providers"
485+
runtime_name = selection["runtime"] if selection else None
486+
capabilities = selection["capabilities"] if selection else []
478487
targets.append(
479488
{
480489
"id": f"native:{agent.id}",
481490
"kind": "native",
482-
"label": f"{agent.name} · {runtime_binding.runtime}",
491+
"label": f"{agent.name} · {runtime_name}" if runtime_name else agent.name,
483492
"agent": {"type": "operations_agent", "name": agent.name},
484493
"runtime": {
485494
"agent_id": agent.id,
486-
"name": runtime_binding.runtime,
495+
"name": runtime_name,
487496
"capabilities": capabilities,
488-
"resume_by_id": "resumable" in capabilities,
497+
"resume_by_id": False,
489498
},
490499
"provider": None,
491500
"models": [],
492501
"default_model_id": None,
493502
"readiness": {
494503
"status": "blocked",
495504
"reason_code": reason_code,
496-
"reason": (
497-
"The published Agent binding is not connected."
498-
if not node_connected
499-
else (
500-
"The Agent binding lacks trusted authentication, isolation, "
501-
"and resumable-session readiness evidence."
502-
)
503-
),
505+
"reason": reason,
504506
},
505-
"setup_url": "/operations-agents",
507+
"setup_url": setup_url,
506508
}
507509
)
508510
return scope.workspace_id, targets

‎backend/services/agent_runtime_selection.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from sqlalchemy import select
66
from sqlalchemy.ext.asyncio import AsyncSession
77

8+
from backend import ws_agent_manager
89
from backend.models.edge_node import EdgeNode
910
from backend.schemas.operations_agent import AgentContractV2, AgentRuntimeBindingV2
10-
from backend import ws_agent_manager
1111

1212

1313
class RuntimeSelectionError(RuntimeError):
@@ -72,9 +72,16 @@ async def select_agent_runtime(
7272
if not isinstance(manifests, dict):
7373
continue
7474
for runtime, advertised in manifests.items():
75-
if not isinstance(runtime, str) or not isinstance(advertised, list):
75+
if (
76+
not isinstance(runtime, str)
77+
or not runtime.strip()
78+
or runtime != runtime.strip()
79+
or not isinstance(advertised, list)
80+
):
7681
continue
77-
capabilities = sorted({item for item in advertised if isinstance(item, str)})
82+
capabilities = sorted(
83+
{item for item in advertised if isinstance(item, str) and item.strip()}
84+
)
7885
if not required.issubset(capabilities):
7986
continue
8087
candidates.append(

‎frontend/lib/api/agent-conversations.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export type AgentExecutionTarget = {
2222
kind: 'provider' | 'native'
2323
label: string
2424
agent: { id?: string; type: string; name: string }
25-
runtime: { node_id: string; name: string; capabilities: string[]; resume_by_id: boolean } | null
25+
runtime: { agent_id: string; name: string | null; capabilities: string[]; resume_by_id: boolean } | null
2626
provider: { id: string; name: string; provider_type: string } | null
2727
models: Array<{ id: string; label: string }>
2828
default_model_id: string | null
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Published native bindings must not break the authorized chat catalog."""
2+
3+
import pytest
4+
5+
from backend import ws_agent_manager
6+
from backend.main import app
7+
from backend.models.edge_node import EdgeNode
8+
from backend.models.identity import Team, User, Workspace, WorkspaceMembership, WorkspaceRole
9+
from backend.models.operations_agent import (
10+
OperationsAgentIdentity,
11+
PublishedOperationsAgentVersion,
12+
)
13+
from backend.security.identity import RequestIdentity, get_request_identity
14+
15+
16+
async def seed_agent(db, *, slug="native", configuration=None):
17+
user = User(subject=f"{slug}@example.test")
18+
workspace = Workspace(name=slug, slug=slug)
19+
db.add_all([user, workspace])
20+
await db.flush()
21+
team = Team(workspace_id=workspace.id, name="Agent owners", slug="owners")
22+
db.add_all(
23+
[
24+
team,
25+
WorkspaceMembership(
26+
workspace_id=workspace.id, user_id=user.id, role=WorkspaceRole.ADMIN
27+
),
28+
]
29+
)
30+
await db.flush()
31+
agent = OperationsAgentIdentity(
32+
workspace_id=workspace.id,
33+
owning_team_id=team.id,
34+
name=f"{slug} assistant",
35+
current_published_version=1,
36+
)
37+
db.add(agent)
38+
await db.flush()
39+
version = PublishedOperationsAgentVersion(
40+
operations_agent_id=agent.id,
41+
version=1,
42+
draft_revision=1,
43+
instructions="Inspect authorized project information.",
44+
model_configuration=configuration
45+
if configuration is not None
46+
else {
47+
"runtime_binding": {
48+
"schema_version": "agent.runtime-binding.v2",
49+
"workflow": "assistant",
50+
"preferred_agent_urls": ["http://private-runner.example:19823"],
51+
"preferred_runtimes": ["codex"],
52+
},
53+
"agent_contract": {
54+
"schema_version": "agent.contract.v2",
55+
"role": "assistant",
56+
"input_schema": {"type": "object"},
57+
"output_schema": {"type": "object"},
58+
"state_schema": {"type": "object"},
59+
"required_capabilities": ["streaming", "workspace_read"],
60+
},
61+
},
62+
published_by_user_id=user.id,
63+
reason="Test published native configuration",
64+
)
65+
db.add(version)
66+
await db.commit()
67+
return user, workspace, agent, version
68+
69+
70+
@pytest.mark.parametrize(
71+
("connected", "capabilities", "reason"),
72+
[
73+
(True, ["streaming", "workspace_read", "resumable"], "native_session_contract_unavailable"),
74+
(False, ["streaming", "workspace_read"], "runtime_node_unavailable"),
75+
(True, ["streaming"], "runtime_node_unavailable"),
76+
],
77+
)
78+
async def test_native_v2_catalog_uses_capability_selection_without_claiming_chat_resume(
79+
client, db_session, monkeypatch, connected, capabilities, reason
80+
):
81+
user, workspace, agent, _version = await seed_agent(db_session)
82+
node = EdgeNode(
83+
url="http://private-runner.example:19823",
84+
protocol="ws",
85+
status="online",
86+
runtimes=["", " ", "codex"],
87+
runtime_capabilities={
88+
"": capabilities,
89+
" ": capabilities,
90+
"codex": ["", " ", *capabilities],
91+
},
92+
)
93+
db_session.add(node)
94+
await db_session.commit()
95+
monkeypatch.setattr(ws_agent_manager, "is_connected", lambda _url: connected)
96+
97+
async def identity():
98+
return RequestIdentity(subject=user.subject)
99+
100+
app.dependency_overrides[get_request_identity] = identity
101+
response = await client.get(
102+
"/api/v1/chat/execution-targets", params={"workspace_id": workspace.id}
103+
)
104+
assert response.status_code == 200
105+
(target,) = response.json()["data"]["targets"]
106+
assert target["id"] == f"native:{agent.id}"
107+
assert target["readiness"]["status"] == "blocked"
108+
assert target["readiness"]["reason_code"] == reason
109+
assert target["setup_url"] == (
110+
"/providers" if reason == "native_session_contract_unavailable" else "/nodes"
111+
)
112+
assert target["runtime"]["resume_by_id"] is False
113+
assert "" not in target["runtime"]["capabilities"]
114+
assert " " not in target["runtime"]["capabilities"]
115+
assert target["runtime"]["name"] == (
116+
"codex" if reason == "native_session_contract_unavailable" else None
117+
)
118+
assert "private-runner" not in response.text
119+
assert "agent_url" not in response.text
120+
assert node.id not in response.text
121+
creation = await client.post(
122+
"/api/v1/chat/sessions",
123+
json={"workspace_id": workspace.id, "execution_target_id": target["id"]},
124+
)
125+
assert creation.status_code == 409
126+
assert creation.json()["detail"]["code"] == "native_runtime_blocked"
127+
128+
129+
@pytest.mark.parametrize("configuration", [{}, {"runtime_binding": {"schema_version": "old"}}])
130+
async def test_incomplete_published_binding_has_repair_reason_and_workspace_isolation(
131+
client, db_session, configuration
132+
):
133+
user, workspace, agent, _version = await seed_agent(db_session, configuration=configuration)
134+
_other_user, other_workspace, other_agent, _other_version = await seed_agent(
135+
db_session, slug="private"
136+
)
137+
138+
async def identity():
139+
return RequestIdentity(subject=user.subject)
140+
141+
app.dependency_overrides[get_request_identity] = identity
142+
response = await client.get(
143+
"/api/v1/chat/execution-targets", params={"workspace_id": workspace.id}
144+
)
145+
assert response.status_code == 200
146+
(target,) = response.json()["data"]["targets"]
147+
assert target["id"] == f"native:{agent.id}"
148+
assert target["readiness"]["reason_code"] == "runtime_binding_invalid"
149+
assert target["setup_url"] == "/operations-agents"
150+
assert other_agent.id not in response.text
151+
forbidden = await client.get(
152+
"/api/v1/chat/execution-targets", params={"workspace_id": other_workspace.id}
153+
)
154+
assert forbidden.status_code == 403

0 commit comments

Comments
 (0)