Skip to content

Commit 82a8a28

Browse files
committed
fix(studio): make dynamic agent creation idempotent
1 parent 9bcf696 commit 82a8a28

80 files changed

Lines changed: 820 additions & 263 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/src/adk/runSseError.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ const SESSION_DETAIL_PATTERN = /session not found/i;
33
const ROUTE_NOT_FOUND_PATTERN = /(?:^|[:\s])not found\s*$/i;
44
const TOOL_ARGUMENT_JSON_PATTERN =
55
/Expecting (?:'[^']+'|\w+)(?: delimiter)?: line \d+ column \d+ \(char \d+\)/i;
6+
const RESOURCE_COLLECTION_EXPIRED_PATTERN =
7+
/Unknown or expired collection_id\s+'[^']+'\.\s*Call collect_resources first\./i;
68

79
const PERSISTENT_MEMORY_HINT =
810
"提示:会话已不存在。使用 in-memory 或 SQLite 短期记忆时,多实例、进程重启或滚动发布都可能导致会话丢失;建议改用基于数据库的持久化短期记忆存储。";
911
const UNSUPPORTED_ROUTE_HINT =
1012
"提示:该 Runtime 未提供会话能力运行接口,可能是 Runtime 版本与当前 Studio 不兼容。";
1113
const TOOL_ARGUMENT_JSON_HINT =
1214
"提示:模型生成的工具参数格式不完整,请重新发送一次。";
15+
const RESOURCE_COLLECTION_EXPIRED_HINT =
16+
"提示:本次资源清单已失效,请重新发送任务;系统会重新收集资源后再创建 Agent。";
1317
export const RUN_SSE_NETWORK_CONFIGURATION_HINT =
1418
"提示:请检查共享公网出口等网络配置,然后重试。";
1519
const RAW_RESPONSE_LABEL = "原始响应:";
@@ -26,6 +30,8 @@ export function formatRunSseError(error: unknown): string {
2630
: `${RAW_RESPONSE_LABEL}${message}`;
2731
if (TOOL_ARGUMENT_JSON_PATTERN.test(message)) {
2832
formatted = appendHint(formatted, TOOL_ARGUMENT_JSON_HINT);
33+
} else if (RESOURCE_COLLECTION_EXPIRED_PATTERN.test(message)) {
34+
return appendHint(formatted, RESOURCE_COLLECTION_EXPIRED_HINT);
2935
} else if (SESSION_NOT_FOUND_PATTERN.test(message)) {
3036
if (SESSION_DETAIL_PATTERN.test(message)) {
3137
formatted = appendHint(formatted, PERSISTENT_MEMORY_HINT);

frontend/src/ui/builtin-tools/CreateAgentToolCards.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,13 @@ export function CreateAgentsCard({ args, response, status }: CreateAgentToolCard
366366
<div className="create-agent-card__agent-grid">
367367
{data.agents.map((agent) => {
368368
const agentStatus = status === "failed" ? "failed" : agent.status;
369+
const agentError = agent.error || (agentStatus === "failed" && topLevelError);
369370
const toolCount = agent.builtinTools.length + agent.pythonTools.length;
370371
return (
371-
<ResourceCard className="create-agent-card__agent-card" key={agent.name}>
372+
<ResourceCard
373+
className={`create-agent-card__agent-card${agentError ? " is-error" : ""}`}
374+
key={agent.name}
375+
>
372376
<ResourceCardHeader
373377
leading={<ResourceIdentityMark seed={agent.name} />}
374378
title={agent.name}
@@ -382,9 +386,9 @@ export function CreateAgentsCard({ args, response, status }: CreateAgentToolCard
382386
{agent.description ? (
383387
<ResourceCardDescription>{agent.description}</ResourceCardDescription>
384388
) : null}
385-
{agent.error || (agentStatus === "failed" && topLevelError) ? (
389+
{agentError ? (
386390
<div className="create-agent-card__agent-result is-error" role="alert">
387-
{agent.error || topLevelError}
391+
{agentError}
388392
</div>
389393
) : null}
390394
<div className="create-agent-card__agent-resources" aria-label={`${agent.name} 具备的资源`}>

frontend/src/ui/builtin-tools/create-agent-tool-cards.css

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,16 @@
202202
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 372px));
203203
align-items: stretch;
204204
justify-content: start;
205-
grid-auto-rows: 140px;
205+
grid-auto-rows: 152px;
206206
gap: 12px;
207207
overflow-y: auto;
208208
overscroll-behavior: contain;
209209
scrollbar-gutter: stable;
210210
}
211211

212212
.create-agent-card__agent-card.resource-card {
213-
height: 140px;
214-
min-height: 140px;
213+
height: 152px;
214+
min-height: 152px;
215215
gap: 0;
216216
padding: 12px;
217217
overflow: visible;
@@ -267,6 +267,12 @@
267267
-webkit-line-clamp: 2;
268268
}
269269

270+
.create-agent-card__agent-card.is-error .resource-card__description {
271+
min-height: 20px;
272+
max-height: 20px;
273+
-webkit-line-clamp: 1;
274+
}
275+
270276
.create-agent-card__agent-resources {
271277
display: flex;
272278
align-items: center;
@@ -503,6 +509,7 @@
503509
}
504510

505511
.create-agent-card__agent-result {
512+
flex: 0 0 18px;
506513
max-height: 18px;
507514
margin: 0;
508515
padding: 0;

frontend/tests/createAgentToolCards.test.mjs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,10 @@ test("combines create_agents input blueprints with partial execution results", a
302302
});
303303

304304
test("renders compact bounded agent cards with resource popovers and separated tool types", () => {
305-
assert.match(cardSource, /<ResourceCard className="create-agent-card__agent-card"/);
305+
assert.match(
306+
cardSource,
307+
/className=\{`create-agent-card__agent-card\$\{agentError \? " is-error" : ""\}`\}/,
308+
);
306309
assert.match(cardSource, /<ResourceIdentityMark seed=\{agent\.name\}/);
307310
assert.match(cardSource, /import \{ ResourceLibraryIcon \} from "\.\.\/icons\/SidebarIcons"/);
308311
assert.match(cardSource, /icon=\{<ResourceLibraryIcon aria-hidden="true" \/>\}/);
@@ -319,7 +322,19 @@ test("renders compact bounded agent cards with resource popovers and separated t
319322
/grid-template-columns:\s*repeat\(auto-fit, minmax\(min\(280px, 100%\), 372px\)\)/,
320323
);
321324
assert.match(cardStyles, /justify-content:\s*start/);
322-
assert.match(cardStyles, /grid-auto-rows:\s*140px/);
325+
assert.match(cardStyles, /grid-auto-rows:\s*152px/);
326+
assert.match(
327+
cardStyles,
328+
/\.create-agent-card__agent-card\.resource-card\s*\{[^}]*height:\s*152px;[^}]*min-height:\s*152px;/s,
329+
);
330+
assert.match(
331+
cardStyles,
332+
/\.create-agent-card__agent-card\.is-error \.resource-card__description\s*\{[^}]*min-height:\s*20px;[^}]*max-height:\s*20px;[^}]*-webkit-line-clamp:\s*1;/s,
333+
);
334+
assert.match(
335+
cardStyles,
336+
/\.create-agent-card__agent-result\s*\{[^}]*flex:\s*0 0 18px;[^}]*max-height:\s*18px;/s,
337+
);
323338
assert.match(
324339
cardStyles,
325340
/\.create-agent-card__agent-card \.resource-card__title-copy h3\s*\{[^}]*font-size:\s*var\(--font-text-md-size, 16px\)/s,

frontend/tests/runSseError.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ test("preserves malformed tool argument details and adds an actionable message",
6363
assert.ok(formatted.endsWith(NETWORK_HINT));
6464
});
6565

66+
test("explains an expired resource collection without blaming public egress", () => {
67+
const error =
68+
"Unknown or expired collection_id 'resources_2b25f87314b6'. Call collect_resources first.";
69+
const formatted = formatRunSseError(error);
70+
assert.ok(formatted.startsWith(`原始响应:${error}`));
71+
assert.match(formatted, //);
72+
assert.match(formatted, //);
73+
assert.doesNotMatch(formatted, //);
74+
});
75+
6676
test("does not claim that a model error was caused by public egress", () => {
6777
const error = "ModelInvocationError: upstream model returned 503";
6878
const formatted = formatRunSseError(error);
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import importlib
16+
from unittest.mock import MagicMock
17+
18+
import pytest
19+
20+
from veadk.cli.agentkit_runtime_iam import (
21+
AGENTKIT_RUNTIME_FULL_ACCESS_POLICY,
22+
ensure_quick_runtime_full_access,
23+
is_agentkit_default_runtime_role,
24+
)
25+
26+
27+
def _install_iam_service(monkeypatch: pytest.MonkeyPatch, service: MagicMock) -> None:
28+
iam_module = importlib.import_module("volcengine.iam.IamService")
29+
monkeypatch.setattr(iam_module, "IamService", lambda: service)
30+
31+
32+
def test_recognizes_current_and_legacy_agentkit_default_roles() -> None:
33+
assert is_agentkit_default_runtime_role(
34+
"AgentKit_Runtime_Default_ServiceRole_abcd123"
35+
)
36+
assert is_agentkit_default_runtime_role(
37+
"trn:iam::123:role/AgentKit-Runtime-Default-ServiceRole-abcd123"
38+
)
39+
assert not is_agentkit_default_runtime_role("CustomerRuntimeRole")
40+
41+
42+
def test_attaches_full_access_to_generated_runtime_role(
43+
monkeypatch: pytest.MonkeyPatch,
44+
) -> None:
45+
service = MagicMock()
46+
service.list_attached_role_policies.return_value = {
47+
"Result": {"AttachedPolicyMetadata": [{"PolicyName": "AgentKitRuntimeAccess"}]}
48+
}
49+
service.attach_role_policy.return_value = {"Result": {}}
50+
_install_iam_service(monkeypatch, service)
51+
52+
assert ensure_quick_runtime_full_access(
53+
"AgentKit_Runtime_Default_ServiceRole_abcd123",
54+
access_key="ak",
55+
secret_key="sk",
56+
)
57+
service.attach_role_policy.assert_called_once_with(
58+
{
59+
"RoleName": "AgentKit_Runtime_Default_ServiceRole_abcd123",
60+
"PolicyName": AGENTKIT_RUNTIME_FULL_ACCESS_POLICY,
61+
"PolicyType": "System",
62+
}
63+
)
64+
65+
66+
def test_keeps_existing_full_access_and_customer_roles_unchanged(
67+
monkeypatch: pytest.MonkeyPatch,
68+
) -> None:
69+
service = MagicMock()
70+
service.list_attached_role_policies.return_value = {
71+
"Result": {
72+
"AttachedPolicyMetadata": [
73+
{"PolicyName": AGENTKIT_RUNTIME_FULL_ACCESS_POLICY}
74+
]
75+
}
76+
}
77+
_install_iam_service(monkeypatch, service)
78+
79+
assert ensure_quick_runtime_full_access(
80+
"AgentKit_Runtime_Default_ServiceRole_abcd123",
81+
access_key="ak",
82+
secret_key="sk",
83+
)
84+
assert not ensure_quick_runtime_full_access(
85+
"CustomerRuntimeRole",
86+
access_key="ak",
87+
secret_key="sk",
88+
)
89+
service.attach_role_policy.assert_not_called()
90+
service.list_attached_role_policies.assert_called_once()

tests/cli/test_generated_agent_backend_codegen.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ def test_quick_mode_codegen_adds_dynamic_agent_toolset_and_managed_rules() -> No
150150
)
151151
assert "resources=[]" in compat_py
152152
assert "request-specific entities" in compat_py
153+
assert "class _ReusableResourceStore" in compat_py
154+
assert "Call create_agents exactly once" in compat_py
153155

154156

155157
def test_quick_mode_compat_backports_offline_snapshot_and_task_context(
@@ -175,11 +177,39 @@ def test_quick_mode_compat_backports_offline_snapshot_and_task_context(
175177
spec.loader.exec_module(module)
176178
module._NATIVE_TASK_CONTEXT = False
177179

180+
store = module._ReusableResourceStore()
181+
snapshot = store.put(
182+
owner="test-invocation",
183+
capabilities=AgentCapabilities(
184+
google_adk_version="2.1.0",
185+
agent_types=["llm", "sequential", "parallel", "loop", "workflow"],
186+
),
187+
resources=[],
188+
)
189+
assert (
190+
store.consume(
191+
collection_id=snapshot.collection_id,
192+
owner="test-invocation",
193+
)
194+
is snapshot
195+
)
196+
assert (
197+
store.consume(
198+
collection_id=snapshot.collection_id,
199+
owner="test-invocation",
200+
)
201+
is snapshot
202+
)
203+
178204
captured: dict[str, object] = {}
205+
create_calls = 0
179206

180207
async def fake_create_agents(self, **kwargs):
208+
nonlocal create_calls
209+
create_calls += 1
210+
await asyncio.sleep(0)
181211
captured.update(kwargs)
182-
return {"ok": True}
212+
return {"ok": True, "handoff_to": "analysis_agent__runtime"}
183213

184214
monkeypatch.setattr(
185215
"veadk.tools.builtin_tools.create_agent.CreateAgentToolset.create_agents",
@@ -201,15 +231,31 @@ async def fake_create_agents(self, **kwargs):
201231
],
202232
}
203233

204-
result = asyncio.run(
205-
toolset.create_agents(
206-
collection_id="",
207-
agents=[blueprint],
208-
handoff_to="analysis_agent",
234+
async def create_twice():
235+
return await asyncio.gather(
236+
toolset.create_agents(
237+
collection_id="",
238+
agents=[blueprint],
239+
handoff_to="analysis_agent",
240+
),
241+
toolset.create_agents(
242+
collection_id="",
243+
agents=[blueprint],
244+
handoff_to="analysis_agent",
245+
),
209246
)
210-
)
211247

212-
assert result == {"ok": True}
248+
result, duplicate = asyncio.run(create_twice())
249+
250+
assert (
251+
result
252+
== duplicate
253+
== {
254+
"ok": True,
255+
"handoff_to": "analysis_agent__runtime",
256+
}
257+
)
258+
assert create_calls == 1
213259
assert str(captured["collection_id"]).startswith("resources_")
214260
compatible_blueprint = captured["agents"][0]
215261
instruction = compatible_blueprint.nodes[0].instruction

0 commit comments

Comments
 (0)