en2zmax revised this gist . Go to revision
1 file changed, 77 insertions
ams-agent(file created)
| @@ -0,0 +1,77 @@ | |||
| 1 | + | Разобрался. Причина — в семантике @agent.tool в pydantic-ai, и в том, что AMS даёт в step один и тот же объект агента на каждой итерации. | |
| 2 | + | Почему падает | |
| 3 | + | @agent.tool → FunctionToolset.add_function() → add_tool(), а там жёсткая проверка: | |
| 4 | + | def add_tool(self, tool): | |
| 5 | + | if tool.name in self.tools: | |
| 6 | + | raise UserError(f'Tool name conflicts with existing tool: {tool.name!r}') | |
| 7 | + | ... | |
| 8 | + | self.tools[tool.name] = tool | |
| 9 | + | step вызывается много раз, но agent переиспользуется (именно поэтому agent.history и set_system_prompt имеют смысл — история копится в одном объекте). Значит на второй итерации регистрация read_file_lines с тем же именем бросает UserError: Tool name conflicts with existing tool: 'read_file_lines' (или молча затирается — в зависимости от версии, в 2.33 именно исключение). | |
| 10 | + | Плюс AMS-обёртка StatefulAgent (это Cython-класс в /opt/ams/app/processors/tools/stateful_agent.so) проксирует .tool на внутренний pydantic_ai.Agent через __getattr__ — так что «re-register» бьёт прямо по тулсету внутреннего агента. | |
| 11 | + | Как правильно (рекомендую вариант 1) | |
| 12 | + | Вариант 1 — вообще не трогать агент, передавать тулсет на каждый run | |
| 13 | + | Тулсет создаётся один раз при импорте модуля, а в ран передаётся через toolsets=. Ничего не накапливается на агенте → конфликта нет по построению: | |
| 14 | + | import os | |
| 15 | + | from datetime import datetime | |
| 16 | + | from pydantic_ai import ModelSettings, RunContext, ModelRetry, FunctionToolset | |
| 17 | + | from pydantic_ai.workspaces import LocalWorkspaceBackend | |
| 18 | + | from pydantic_ai_harness.filesystem import FileSystem | |
| 19 | + | ||
| 20 | + | CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| 21 | + | system_prompt = open(os.path.join(CURRENT_DIR, 'system_prompt.txt')).read() | |
| 22 | + | ||
| 23 | + | # инструмент объявлен на уровне модуля (один раз) | |
| 24 | + | async def read_file_lines(ctx: RunContext, path: str, | |
| 25 | + | start_line: int = 1, max_lines: int = 50) -> str: | |
| 26 | + | """Read a specific range of lines from a text file...""" | |
| 27 | + | if start_line < 1: | |
| 28 | + | raise ModelRetry('start_line must be 1 or greater.') | |
| 29 | + | try: | |
| 30 | + | content = await ctx.workspace.read_text(path) | |
| 31 | + | except FileNotFoundError: | |
| 32 | + | raise ModelRetry(f"File '{path}' does not exist.") | |
| 33 | + | lines = content.splitlines() | |
| 34 | + | start_idx = start_line - 1 | |
| 35 | + | end_idx = start_idx + max_lines | |
| 36 | + | chunk = lines[start_idx:end_idx] | |
| 37 | + | if not chunk: | |
| 38 | + | return f"EOF: No lines found in range {start_line}-{end_idx}." | |
| 39 | + | return f"[Lines {start_line}-{min(end_idx, len(lines))} of {len(lines)}]:\n" + "\n".join(chunk) | |
| 40 | + | ||
| 41 | + | # создаётся ОДИН раз | |
| 42 | + | TOOLSET = FunctionToolset(tools=[read_file_lines]) | |
| 43 | + | WORKSPACE = LocalWorkspaceBackend('./logs') | |
| 44 | + | ||
| 45 | + | def step(agent, state, params): | |
| 46 | + | dt = datetime.now().strftime("%d.%m.%Y %H:%M") | |
| 47 | + | agent.set_system_prompt(system_prompt) | |
| 48 | + | ||
| 49 | + | model_settings = ModelSettings(**params['sampling_params']) | |
| 50 | + | prompt = params['prompt'] | |
| 51 | + | # ... ваш блок с params['file'] ... | |
| 52 | + | ||
| 53 | + | if not prompt: | |
| 54 | + | return | |
| 55 | + | ||
| 56 | + | with agent.run_stream_sync( | |
| 57 | + | prompt, | |
| 58 | + | model_settings=model_settings, | |
| 59 | + | toolsets=[TOOLSET], # <-- тул приходит на этот ран, не мутируя агента | |
| 60 | + | capabilities=[FileSystem()], | |
| 61 | + | workspace=WORKSPACE, | |
| 62 | + | ) as stream: | |
| 63 | + | for chunk in stream.stream_text(delta=True): | |
| 64 | + | yield {'type': 'chunk', 'data': chunk} | |
| 65 | + | Вариант 2 — оставить @agent.tool, но регистрировать идемпотентно | |
| 66 | + | Если хочется именно закрепить тул на агенте — вынесите функцию из step и защититесь флагом на объекте агента (не глобальным bool — в процессе может жить несколько агентов): | |
| 67 | + | def _register_tools_once(agent): | |
| 68 | + | if getattr(agent, "_file_tools_registered", False): | |
| 69 | + | return | |
| 70 | + | agent.tool(read_file_lines) | |
| 71 | + | agent._file_tools_registered = True | |
| 72 | + | и в начале step: _register_tools_once(agent). | |
| 73 | + | Отличие: вариант 1 не зависит от того, переиспользуется ли объект агента/обёртки; вариант 2 надёжен, только пока AMS передаёт тот же самый объект agent. Поэтому вариант 1 безопаснее. | |
| 74 | + | Ещё два момента | |
| 75 | + | - capabilities=[FileSystem()] уже даёт файловые тулы (read_file, edit_file, list_files, grep, с параметрами max_read_lines/max_read_chars и т.п.). Так что ваш read_file_lines может быть избыточен — если нужен именно построчный диапазон + ModelRetry, оставляйте, но дублирующиеся имена (read_file) не создавайте. | |
| 76 | + | - Проверьте окружение на сервере. На 192.168.102.120 в вене AMS стоит pydantic_ai 2.33.0, а в нём нет pydantic_ai.workspaces, не установлен pydantic_ai_harness, и параметра workspace= у run_stream_sync нет (есть deps, toolsets, capabilities). Актуальные версии — pydantic-ai 2.56.0 / pydantic-ai-harness 0.56.0. То есть этот handler.py на текущем серверном AMS не заимпортится — нужен либо апгрейд/установка harness, либо адаптация под 2.33 (capabilities=[FileSystem()] там есть, а workspace-механику придётся заменить). | |
| 77 | + | То же самое с сигнатурой: в хелпе AMS (/opt/ams/app/help/agents/index.html) контракт — step(params, state, agent), но «имена аргументов являются частью контракта», поэтому ваш порядок (agent, state, params) сработает, если AMS вызывает по ключевым словам. | |
Newer
Older