Skip to content

Commit f9b5e67

Browse files
Sodawyxclaude
andcommitted
fix(agent_runtime): paginate workspace lookup & avoid input mutation
Address two issues raised in PR #102 review by Ohyee: 1. Workspace lookup was hardcoded to a single page (page_size=50). If the upstream `name=` filter is server-side prefix/fuzzy match, a busy account can easily exceed 50 same-prefix workspaces and the resolver would silently miss the target. `_lookup_sync` / `_lookup_async` now paginate, accumulating candidates across pages and short-circuiting when a page returns fewer than `_PAGE_SIZE` rows; a `_MAX_PAGES=20` safety cap (1000 candidates) prevents runaway loops on misbehaving upstreams. 2. The workspace_name resolution path mutated the caller's input object (`input.workspace_id = ...; input.workspace_name = None`), which is surprising for a side-effect-free convenience field. Replaced with `input = input.model_copy(update={...})` so the caller's instance is untouched. Applied to create / create_async / list / list_async in client.py and mirrored into the codegen template. Tests: - 4 new pagination cases (multi-page accumulation, short-page early stop, MAX_PAGES cap, async multi-page). - 4 existing mutation assertions flipped to "caller's input stays unchanged; OpenAPI request carries the resolved id". agent_runtime suite 266 passed / 2 skipped; `_workspace.py` 95.62% line/branch. Full repo 3481 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Sodawyx <sodawyx@126.com>
1 parent bbef5b9 commit f9b5e67

4 files changed

Lines changed: 193 additions & 45 deletions

File tree

agentrun/agent_runtime/__client_async_template.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,12 @@ async def create_async(
9090
)
9191
if input.workspace_name is not None:
9292
cfg = Config.with_configs(self.config, config)
93-
input.workspace_id = await resolve_workspace_id_by_name_async(
93+
resolved_id = await resolve_workspace_id_by_name_async(
9494
input.workspace_name, cfg
9595
)
96-
input.workspace_name = None
96+
input = input.model_copy(
97+
update={"workspace_id": resolved_id, "workspace_name": None}
98+
)
9799

98100
if input.network_configuration is None:
99101
input.network_configuration = NetworkConfig()
@@ -244,20 +246,22 @@ async def list_async(
244246
)
245247
if input.workspace_name is not None or input.workspace_names:
246248
cfg = Config.with_configs(self.config, config)
249+
update: dict = {}
247250
if input.workspace_name is not None:
248-
input.workspace_id = (
251+
update["workspace_id"] = (
249252
await resolve_workspace_id_by_name_async(
250253
input.workspace_name, cfg
251254
)
252255
)
253-
input.workspace_name = None
256+
update["workspace_name"] = None
254257
if input.workspace_names:
255-
input.workspace_ids = (
258+
update["workspace_ids"] = (
256259
await resolve_workspace_ids_by_names_async(
257260
input.workspace_names, cfg
258261
)
259262
)
260-
input.workspace_names = None
263+
update["workspace_names"] = None
264+
input = input.model_copy(update=update)
261265

262266
results = await self.__control_api.list_agent_runtimes_async(
263267
ListAgentRuntimesRequest().from_map(input.model_dump()),

agentrun/agent_runtime/_workspace.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
# Value 为解析得到的 workspace_id。
3131
_RESOLVE_CACHE: Dict[Tuple[str, str, str], str] = {}
3232

33+
# 翻页参数:ListWorkspaces 的 name= 参数在服务端可能是 prefix/fuzzy 匹配,
34+
# 单页 50 条不足以覆盖海量同前缀场景,因此累积所有页再做 exact match。
35+
_PAGE_SIZE = 50
36+
# 安全上限:避免上游异常导致死循环;20 页 × 50 条 = 1000 个候选,
37+
# 同名 / 同前缀 workspace 远超该值的概率极低。
38+
_MAX_PAGES = 20
39+
3340

3441
def _cache_key(cfg: Config, name: str) -> Tuple[str, str, str]:
3542
return (
@@ -113,35 +120,69 @@ def _lookup_sync(
113120
self, name: str, config: Optional[Config] = None
114121
) -> Optional[Workspace]:
115122
client = self._get_client(config)
123+
accumulated: List[Workspace] = []
124+
page_number = 1
116125
try:
117-
response = client.list_workspaces(
118-
ListWorkspacesRequest(name=name, page_size="50")
119-
)
126+
while page_number <= _MAX_PAGES:
127+
response = client.list_workspaces(
128+
ListWorkspacesRequest(
129+
name=name,
130+
page_size=str(_PAGE_SIZE),
131+
page_number=str(page_number),
132+
)
133+
)
134+
workspaces = (
135+
getattr(
136+
getattr(response.body, "data", None),
137+
"workspaces",
138+
None,
139+
)
140+
or []
141+
)
142+
if not workspaces:
143+
break
144+
accumulated.extend(workspaces)
145+
if len(workspaces) < _PAGE_SIZE:
146+
break
147+
page_number += 1
120148
except (ClientException, ServerException) as e:
121149
_raise_for_tea_exception(e)
122150
raise
123-
workspaces = (
124-
getattr(getattr(response.body, "data", None), "workspaces", None)
125-
or []
126-
)
127-
return _pick_exact_match(workspaces, name)
151+
return _pick_exact_match(accumulated, name)
128152

129153
async def _lookup_async(
130154
self, name: str, config: Optional[Config] = None
131155
) -> Optional[Workspace]:
132156
client = self._get_client(config)
157+
accumulated: List[Workspace] = []
158+
page_number = 1
133159
try:
134-
response = await client.list_workspaces_async(
135-
ListWorkspacesRequest(name=name, page_size="50")
136-
)
160+
while page_number <= _MAX_PAGES:
161+
response = await client.list_workspaces_async(
162+
ListWorkspacesRequest(
163+
name=name,
164+
page_size=str(_PAGE_SIZE),
165+
page_number=str(page_number),
166+
)
167+
)
168+
workspaces = (
169+
getattr(
170+
getattr(response.body, "data", None),
171+
"workspaces",
172+
None,
173+
)
174+
or []
175+
)
176+
if not workspaces:
177+
break
178+
accumulated.extend(workspaces)
179+
if len(workspaces) < _PAGE_SIZE:
180+
break
181+
page_number += 1
137182
except (ClientException, ServerException) as e:
138183
_raise_for_tea_exception(e)
139184
raise
140-
workspaces = (
141-
getattr(getattr(response.body, "data", None), "workspaces", None)
142-
or []
143-
)
144-
return _pick_exact_match(workspaces, name)
185+
return _pick_exact_match(accumulated, name)
145186

146187

147188
def resolve_workspace_id_by_name(

agentrun/agent_runtime/client.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ async def create_async(
100100
)
101101
if input.workspace_name is not None:
102102
cfg = Config.with_configs(self.config, config)
103-
input.workspace_id = await resolve_workspace_id_by_name_async(
103+
resolved_id = await resolve_workspace_id_by_name_async(
104104
input.workspace_name, cfg
105105
)
106-
input.workspace_name = None
106+
input = input.model_copy(
107+
update={"workspace_id": resolved_id, "workspace_name": None}
108+
)
107109

108110
if input.network_configuration is None:
109111
input.network_configuration = NetworkConfig()
@@ -159,10 +161,12 @@ def create(
159161
)
160162
if input.workspace_name is not None:
161163
cfg = Config.with_configs(self.config, config)
162-
input.workspace_id = resolve_workspace_id_by_name(
164+
resolved_id = resolve_workspace_id_by_name(
163165
input.workspace_name, cfg
164166
)
165-
input.workspace_name = None
167+
input = input.model_copy(
168+
update={"workspace_id": resolved_id, "workspace_name": None}
169+
)
166170

167171
if input.network_configuration is None:
168172
input.network_configuration = NetworkConfig()
@@ -394,20 +398,22 @@ async def list_async(
394398
)
395399
if input.workspace_name is not None or input.workspace_names:
396400
cfg = Config.with_configs(self.config, config)
401+
update: dict = {}
397402
if input.workspace_name is not None:
398-
input.workspace_id = (
403+
update["workspace_id"] = (
399404
await resolve_workspace_id_by_name_async(
400405
input.workspace_name, cfg
401406
)
402407
)
403-
input.workspace_name = None
408+
update["workspace_name"] = None
404409
if input.workspace_names:
405-
input.workspace_ids = (
410+
update["workspace_ids"] = (
406411
await resolve_workspace_ids_by_names_async(
407412
input.workspace_names, cfg
408413
)
409414
)
410-
input.workspace_names = None
415+
update["workspace_names"] = None
416+
input = input.model_copy(update=update)
411417

412418
results = await self.__control_api.list_agent_runtimes_async(
413419
ListAgentRuntimesRequest().from_map(input.model_dump()),
@@ -458,16 +464,18 @@ def list(
458464
)
459465
if input.workspace_name is not None or input.workspace_names:
460466
cfg = Config.with_configs(self.config, config)
467+
update: dict = {}
461468
if input.workspace_name is not None:
462-
input.workspace_id = resolve_workspace_id_by_name(
469+
update["workspace_id"] = resolve_workspace_id_by_name(
463470
input.workspace_name, cfg
464471
)
465-
input.workspace_name = None
472+
update["workspace_name"] = None
466473
if input.workspace_names:
467-
input.workspace_ids = resolve_workspace_ids_by_names(
474+
update["workspace_ids"] = resolve_workspace_ids_by_names(
468475
input.workspace_names, cfg
469476
)
470-
input.workspace_names = None
477+
update["workspace_names"] = None
478+
input = input.model_copy(update=update)
471479

472480
results = self.__control_api.list_agent_runtimes(
473481
ListAgentRuntimesRequest().from_map(input.model_dump()),

tests/unittests/agent_runtime/test_workspace.py

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,81 @@ def test_resolve_many_async(self, monkeypatch):
242242
assert result == "ws-a,ws-b"
243243

244244

245+
class TestResolveWorkspacePagination:
246+
"""翻页累积:避免 server-side name 模糊匹配下 50 条单页漏匹配。"""
247+
248+
def test_paginates_until_exact_match_across_pages(self, monkeypatch):
249+
# 第 1 页 50 条都是前缀匹配但非 exact;第 2 页才含 exact,必须翻页
250+
page1 = [_make_ws(f"my-ws-{i}", f"ws-p1-{i}") for i in range(50)]
251+
page2 = [_make_ws("my-ws", "ws-target"), _make_ws("my-ws-x", "ws-x")]
252+
client = MagicMock()
253+
client.list_workspaces.side_effect = [
254+
_make_response(page1),
255+
_make_response(page2),
256+
]
257+
monkeypatch.setattr(
258+
ws_mod._WorkspaceResolver,
259+
"_get_client",
260+
lambda self, config=None: client,
261+
)
262+
assert resolve_workspace_id_by_name("my-ws") == "ws-target"
263+
# 必须翻到第二页才能找到,因此至少 2 次调用
264+
assert client.list_workspaces.call_count == 2
265+
# page_number 应该递增
266+
calls = client.list_workspaces.call_args_list
267+
assert calls[0].args[0].page_number == "1"
268+
assert calls[1].args[0].page_number == "2"
269+
270+
def test_short_page_breaks_pagination_early(self, monkeypatch):
271+
# 单页返回 < page_size 即视为末页,不再翻页
272+
client = MagicMock()
273+
client.list_workspaces.return_value = _make_response(
274+
[_make_ws("my-ws", "ws-1")]
275+
)
276+
monkeypatch.setattr(
277+
ws_mod._WorkspaceResolver,
278+
"_get_client",
279+
lambda self, config=None: client,
280+
)
281+
assert resolve_workspace_id_by_name("my-ws") == "ws-1"
282+
# 仅查 1 页就停(mock 返回 1 条 < 50)
283+
assert client.list_workspaces.call_count == 1
284+
285+
def test_pagination_respects_max_pages_cap(self, monkeypatch):
286+
# 异常情况:上游一直返回满页(不存在 exact match),
287+
# 必须在 _MAX_PAGES 处停止,避免死循环。
288+
full_page = [_make_ws(f"prefix-{i}", f"id-{i}") for i in range(50)]
289+
client = MagicMock()
290+
client.list_workspaces.return_value = _make_response(full_page)
291+
monkeypatch.setattr(
292+
ws_mod._WorkspaceResolver,
293+
"_get_client",
294+
lambda self, config=None: client,
295+
)
296+
with pytest.raises(ResourceNotExistError):
297+
resolve_workspace_id_by_name("absent-target")
298+
# 不应超过安全上限
299+
assert client.list_workspaces.call_count == ws_mod._MAX_PAGES
300+
301+
def test_async_paginates_across_pages(self, monkeypatch):
302+
page1 = [_make_ws(f"my-ws-{i}", f"ws-p1-{i}") for i in range(50)]
303+
page2 = [_make_ws("my-ws", "ws-target-async")]
304+
client = MagicMock()
305+
client.list_workspaces_async = AsyncMock(
306+
side_effect=[_make_response(page1), _make_response(page2)]
307+
)
308+
monkeypatch.setattr(
309+
ws_mod._WorkspaceResolver,
310+
"_get_client",
311+
lambda self, config=None: client,
312+
)
313+
assert (
314+
asyncio.run(resolve_workspace_id_by_name_async("my-ws"))
315+
== "ws-target-async"
316+
)
317+
assert client.list_workspaces_async.await_count == 2
318+
319+
245320
class TestClientCreateWorkspaceResolution:
246321
"""create 集成测试:workspace_name 自动转换为 workspace_id"""
247322

@@ -282,9 +357,13 @@ def test_create_with_workspace_name_resolves(
282357
args, _ = mock_resolve.call_args
283358
assert args[0] == "my-ws"
284359
assert isinstance(args[1], Config)
285-
# 调用 API 前,应把 workspace_name 清空且 workspace_id 写好
286-
assert inp.workspace_id == "ws-resolved"
287-
assert inp.workspace_name is None
360+
# 调用方传入的 input 对象不应被 mutate
361+
assert inp.workspace_id is None
362+
assert inp.workspace_name == "my-ws"
363+
# OpenAPI 收到的对象应带解析后的 workspace_id(workspace_name
364+
# 是 SDK 侧便利字段,OpenAPI 模型本就没有)
365+
api_input = mock_control_api.create_agent_runtime.call_args.args[0]
366+
assert api_input.workspace_id == "ws-resolved"
288367

289368
@patch(CONTROL_API_PATH)
290369
def test_create_rejects_both_workspace_fields(self, mock_control_api_class):
@@ -341,8 +420,13 @@ def test_create_async_resolves_workspace_name(
341420
args, _ = mock_resolve_async.call_args
342421
assert args[0] == "my-ws"
343422
assert isinstance(args[1], Config)
344-
assert inp.workspace_id == "ws-resolved-async"
345-
assert inp.workspace_name is None
423+
# 调用方传入的 input 对象不应被 mutate
424+
assert inp.workspace_id is None
425+
assert inp.workspace_name == "my-ws"
426+
api_input = mock_control_api.create_agent_runtime_async.call_args.args[
427+
0
428+
]
429+
assert api_input.workspace_id == "ws-resolved-async"
346430

347431

348432
class TestClientListWorkspaceResolution:
@@ -386,10 +470,15 @@ def test_list_resolves_workspace_name_and_names(
386470
many_args, _ = mock_resolve_many.call_args
387471
assert many_args[0] == "ws-a,ws-b"
388472
assert isinstance(many_args[1], Config)
389-
assert inp.workspace_id == "ws-1"
390-
assert inp.workspace_ids == "ws-1,ws-2"
391-
assert inp.workspace_name is None
392-
assert inp.workspace_names is None
473+
# 调用方传入的 input 对象不应被 mutate
474+
assert inp.workspace_id is None
475+
assert inp.workspace_ids is None
476+
assert inp.workspace_name == "my-ws"
477+
assert inp.workspace_names == "ws-a,ws-b"
478+
# OpenAPI 收到的对象应带解析后的 ID
479+
api_input = mock_control_api.list_agent_runtimes.call_args.args[0]
480+
assert api_input.workspace_id == "ws-1"
481+
assert api_input.workspace_ids == "ws-1,ws-2"
393482

394483
@patch(CONTROL_API_PATH)
395484
def test_list_rejects_both_singular(self, mock_control_api_class):
@@ -461,8 +550,14 @@ def test_list_async_resolves(
461550
many_args, _ = mock_resolve_many_async.call_args
462551
assert many_args[0] == "ws-a,ws-b"
463552
assert isinstance(many_args[1], Config)
464-
assert inp.workspace_id == "ws-1"
465-
assert inp.workspace_ids == "ws-1,ws-2"
553+
# 调用方传入的 input 对象不应被 mutate
554+
assert inp.workspace_id is None
555+
assert inp.workspace_ids is None
556+
assert inp.workspace_name == "my-ws"
557+
assert inp.workspace_names == "ws-a,ws-b"
558+
api_input = mock_control_api.list_agent_runtimes_async.call_args.args[0]
559+
assert api_input.workspace_id == "ws-1"
560+
assert api_input.workspace_ids == "ws-1,ws-2"
466561

467562

468563
class TestClientEffectiveConfig:

0 commit comments

Comments
 (0)