From 6e71a288e4d923bda74edfc0f24edd27b3024ac3 Mon Sep 17 00:00:00 2001 From: Justin Gao Date: Mon, 27 Jul 2026 11:45:14 +0800 Subject: [PATCH] chore(sync): adapt to upstream v0.82.1 --- CHANGELOG.md | 18 ++ README.md | 4 +- SYNC.md | 1 + UPSTREAM_VERSION | 2 +- packages/pi-agent-core/PORTING.md | 10 +- packages/pi-agent-core/pyproject.toml | 4 +- .../src/pi_agent_core/__init__.py | 4 +- .../src/pi_agent_core/agent_loop.py | 9 +- .../src/pi_agent_core/harness/compaction.py | 12 +- .../pi-agent-core/tests/test_compaction.py | 30 +++ packages/pi-agent-core/tests/test_smoke.py | 4 +- packages/pi-ai/PORTING.md | 13 +- packages/pi-ai/pyproject.toml | 2 +- packages/pi-ai/src/pi_ai/__init__.py | 4 +- .../pi-ai/src/pi_ai/constrained_sampling.py | 162 +++++++++++++++ packages/pi-ai/src/pi_ai/provider_retry.py | 123 +++++++++++ .../src/pi_ai/providers/anthropic_provider.py | 43 ++-- .../src/pi_ai/providers/openai_provider.py | 194 +++++++++++++++--- packages/pi-ai/src/pi_ai/retry.py | 3 + packages/pi-ai/src/pi_ai/types.py | 4 + packages/pi-ai/tests/test_anthropic_logic.py | 19 ++ .../pi-ai/tests/test_constrained_sampling.py | 84 ++++++++ packages/pi-ai/tests/test_openai_logic.py | 93 ++++++++- packages/pi-ai/tests/test_provider_retry.py | 47 +++++ packages/pi-ai/tests/test_retry.py | 2 + packages/pi-ai/tests/test_smoke.py | 4 +- packages/pi-coding-agent/PORTING.md | 2 +- packages/pi-coding-agent/pyproject.toml | 6 +- .../src/pi_coding_agent/__init__.py | 4 +- packages/pi-coding-agent/tests/test_smoke.py | 4 +- packages/pi-server/PORTING.md | 2 +- packages/pi-server/pyproject.toml | 4 +- packages/pi-server/src/pi_server/__init__.py | 4 +- packages/pi-server/tests/test_smoke.py | 4 +- packages/pi-storage-sqlite/PORTING.md | 2 +- packages/pi-storage-sqlite/pyproject.toml | 6 +- .../src/pi_storage_sqlite/__init__.py | 4 +- .../pi-storage-sqlite/tests/test_smoke.py | 4 +- uv.lock | 10 +- 39 files changed, 856 insertions(+), 95 deletions(-) create mode 100644 packages/pi-ai/src/pi_ai/constrained_sampling.py create mode 100644 packages/pi-ai/src/pi_ai/provider_retry.py create mode 100644 packages/pi-ai/tests/test_constrained_sampling.py create mode 100644 packages/pi-ai/tests/test_provider_retry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4517683..9bc8a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ 本项目在 `CHANGELOG.md` 中保留公共的、用户可见的变更记录。 具体的提交记录参见 [GitHub Releases](https://github.com/earendil-works/pi-py/releases)。 +## 0.82.1 (2026-07-27) + +对齐上游 v0.82.1。 + +### 新增 + +- `Tool.constrainedSampling`:支持 strict JSON Schema 与 OpenAI Lark/regex grammar 工具。 +- OpenAI 与 Anthropic provider 请求级可取消重试,并遵循服务端 retry headers。 + +### 修复 + +- DNS `getaddrinfo`、`ENOTFOUND`、`EAI_AGAIN` 传输失败可触发 assistant 重试。 +- Compaction 摘要请求使用独立 routing session,并禁用 prompt-cache 写入。 + +### 维护 + +- 五个 Python 包统一升级到 0.82.1,内部依赖范围更新为 `<0.83`。 + ## 0.81.1 (2026-07-22) — 初始基线 首次发布,对齐上游 v0.81.1。 diff --git a/README.md b/README.md index 98f1bda..107c7aa 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## 同步状态 -- **当前对齐版本**:[`v0.81.1`](./UPSTREAM_VERSION)(2026-07-21) +- **当前对齐版本**:[`v0.82.1`](./UPSTREAM_VERSION)(2026-07-25) - **同步策略**:仅在上游发布 `0.x.0`(minor)时集中同步,详见 [`SYNC.md`](./SYNC.md) | 包 | 上游对应 | 状态 | 说明 | @@ -139,7 +139,7 @@ uv run mypy # 类型检查(strict) ## 路线图 -- [x] 5 包基线完成(对齐上游 v0.81.1) +- [x] 5 包基线完成(对齐上游 v0.82.1) - [x] OpenAI/DeepSeek provider 真实验证 - [x] Anthropic provider(纯逻辑测试,待真实 API 验证) - [ ] Google / Mistral / Bedrock provider diff --git a/SYNC.md b/SYNC.md index 70f8aa4..fc7463c 100644 --- a/SYNC.md +++ b/SYNC.md @@ -58,3 +58,4 @@ LLM provider 的 API 变动频繁,两个 minor 之间可能积累"上游已修 | 日期 | 上游版本 | 说明 | |---|---|---| | 2026-07-22 | 0.81.1 | 推翻重建,建立新基线(对应上游 v0.81.1) | +| 2026-07-27 | 0.82.1 | 适配受约束工具采样、可取消 provider 重试与摘要请求隔离 | diff --git a/UPSTREAM_VERSION b/UPSTREAM_VERSION index 11df8c6..701792d 100644 --- a/UPSTREAM_VERSION +++ b/UPSTREAM_VERSION @@ -1 +1 @@ -0.81.1 +0.82.1 diff --git a/packages/pi-agent-core/PORTING.md b/packages/pi-agent-core/PORTING.md index 0ca9669..664f718 100644 --- a/packages/pi-agent-core/PORTING.md +++ b/packages/pi-agent-core/PORTING.md @@ -1,6 +1,6 @@ # pi-agent-core 移植注记 -对应上游:[`@earendil-works/pi-agent-core`](https://github.com/earendil-works/pi/tree/main/packages/agent)(v0.81.1) +对应上游:[`@earendil-works/pi-agent-core`](https://github.com/earendil-works/pi/tree/main/packages/agent)(v0.82.1) ## 有意偏离上游 @@ -15,6 +15,14 @@ (暂无) +## v0.82.1 同步说明 + +- Compaction/summary 请求使用独立 routing session,并强制 + `cache_retention="none"`,避免污染主会话缓存。 +- Agent tools 会将 `constrained_sampling` 透传到 pi-ai。 +- 上游新增的 Harness execution tools 与本仓库 `pi-coding-agent` 工具集职责重叠; + 当前精简 Harness 尚未公开 `ExecutionEnv`/`toolContext`,因此未引入不完整兼容层。 + ## 待办 - [ ] agent-loop.ts(无状态循环引擎) diff --git a/packages/pi-agent-core/pyproject.toml b/packages/pi-agent-core/pyproject.toml index a31cf25..0b713f7 100644 --- a/packages/pi-agent-core/pyproject.toml +++ b/packages/pi-agent-core/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "pi-agent-core" -version = "0.81.1" +version = "0.82.1" description = "Python port of @earendil-works/pi-agent-core — General-purpose agent runtime" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.11" authors = [{ name = "Justin Gao" }] dependencies = [ - "pi-ai>=0.81.1,<0.82", + "pi-ai>=0.82.1,<0.83", "pydantic>=2.7", "pyyaml>=6", ] diff --git a/packages/pi-agent-core/src/pi_agent_core/__init__.py b/packages/pi-agent-core/src/pi_agent_core/__init__.py index 12b4244..78c0f49 100644 --- a/packages/pi-agent-core/src/pi_agent_core/__init__.py +++ b/packages/pi-agent-core/src/pi_agent_core/__init__.py @@ -23,8 +23,8 @@ async def execute(self, tool_call_id, params, cancel_event, on_update): from __future__ import annotations -__version__ = "0.81.1" -__upstream_ref__ = "earendil-works/pi@v0.81.1" +__version__ = "0.82.1" +__upstream_ref__ = "earendil-works/pi@v0.82.1" # ---- 类型 ---- # ---- 有状态 Agent ---- diff --git a/packages/pi-agent-core/src/pi_agent_core/agent_loop.py b/packages/pi-agent-core/src/pi_agent_core/agent_loop.py index 485b45a..d474abd 100644 --- a/packages/pi-agent-core/src/pi_agent_core/agent_loop.py +++ b/packages/pi-agent-core/src/pi_agent_core/agent_loop.py @@ -346,7 +346,14 @@ def _convert_tools(tools: list[AgentTool]) -> list[Any]: out = [] for t in tools: params = t.parameters - out.append(Tool(name=t.name, description=t.description, parameters=params)) + out.append( + Tool( + name=t.name, + description=t.description, + parameters=params, + constrained_sampling=getattr(t, "constrained_sampling", None), + ) + ) return out diff --git a/packages/pi-agent-core/src/pi_agent_core/harness/compaction.py b/packages/pi-agent-core/src/pi_agent_core/harness/compaction.py index 5e85ffb..3f51fe7 100644 --- a/packages/pi-agent-core/src/pi_agent_core/harness/compaction.py +++ b/packages/pi-agent-core/src/pi_agent_core/harness/compaction.py @@ -16,6 +16,7 @@ from __future__ import annotations import json +import uuid from dataclasses import dataclass, field from typing import Any @@ -187,11 +188,12 @@ async def generate_summary( from pi_ai import Context, SimpleStreamOptions ctx = Context(system_prompt=SUMMARIZATION_SYSTEM_PROMPT, messages=[ctx_msg]) - opts = ( - SimpleStreamOptions(max_tokens=2000, **options) - if options - else SimpleStreamOptions(max_tokens=2000) - ) + isolated_options = { + **options, + "session_id": str(uuid.uuid4()), + "cache_retention": "none", + } + opts = SimpleStreamOptions(max_tokens=2000, **isolated_options) result = await complete_simple(model, ctx, opts) # 提取文本 if result.content and isinstance(result.content[0], TextContent): diff --git a/packages/pi-agent-core/tests/test_compaction.py b/packages/pi-agent-core/tests/test_compaction.py index 10a6ee0..a9f8aa8 100644 --- a/packages/pi-agent-core/tests/test_compaction.py +++ b/packages/pi-agent-core/tests/test_compaction.py @@ -12,6 +12,7 @@ ) from pi_ai import ( AssistantMessage, + Model, TextContent, ToolCall, ToolResultMessage, @@ -144,3 +145,32 @@ def test_find_cut_point_avoids_tool_result(): def test_find_cut_point_empty(): assert find_cut_point([], keep_recent_tokens=100) == 0 + + +async def test_generate_summary_isolates_routing_session_and_disables_cache(monkeypatch): + from pi_agent_core.harness import compaction + + captured = [] + + async def fake_complete(model, context, options): + captured.append(options) + return AssistantMessage(content=[TextContent(text="summary")]) + + monkeypatch.setattr(compaction, "complete_simple", fake_complete) + model = Model( + id="test", + name="test", + api="openai-completions", + provider="test", + base_url="https://example.com", + ) + + first = await compaction.generate_summary( + model, [_user("first")], session_id="original", cache_retention="long" + ) + second = await compaction.generate_summary(model, [_user("second")]) + + assert first == second == "summary" + assert captured[0].cache_retention == "none" + assert captured[0].session_id != "original" + assert captured[0].session_id != captured[1].session_id diff --git a/packages/pi-agent-core/tests/test_smoke.py b/packages/pi-agent-core/tests/test_smoke.py index d2726e9..d04d305 100644 --- a/packages/pi-agent-core/tests/test_smoke.py +++ b/packages/pi-agent-core/tests/test_smoke.py @@ -4,5 +4,5 @@ def test_import() -> None: import pi_agent_core - assert pi_agent_core.__version__ - assert pi_agent_core.__upstream_ref__ == "earendil-works/pi@v0.81.1" + assert pi_agent_core.__version__ == "0.82.1" + assert pi_agent_core.__upstream_ref__ == "earendil-works/pi@v0.82.1" diff --git a/packages/pi-ai/PORTING.md b/packages/pi-ai/PORTING.md index 0937b83..7cb1401 100644 --- a/packages/pi-ai/PORTING.md +++ b/packages/pi-ai/PORTING.md @@ -1,6 +1,6 @@ # pi-ai 移植注记 -对应上游:[`@earendil-works/pi-ai`](https://github.com/earendil-works/pi/tree/main/packages/ai)(v0.81.1) +对应上游:[`@earendil-works/pi-ai`](https://github.com/earendil-works/pi/tree/main/packages/ai)(v0.82.1) ## 进度 @@ -16,6 +16,7 @@ | providers/openai(Chat Completions) | ✅ | `api/openai-completions.ts` | | providers/anthropic(含 thinking 支持) | ✅ | `api/anthropic-messages.ts` | | retry(重试工具) | ✅ | `utils/retry.ts` | +| constrained sampling(strict JSON Schema + OpenAI grammar) | ✅ | `api/constrained-sampling.ts` | | providers/google / mistral / bedrock | 🟡 后续 | `api/*.ts` | | auth(OAuth) | 🟡 后续 | `auth/*` | | images(图像生成) | 🟡 后续 | `images*.ts` | @@ -47,6 +48,16 @@ (暂无) +## v0.82.1 同步说明 + +- `Tool.constrainedSampling` 已映射为 Pydantic 的 `constrained_sampling` + (序列化别名保持 camelCase)。 +- OpenAI Chat Completions 支持 strict JSON Schema 与 Lark/regex grammar 工具定义; + Anthropic Messages 支持 strict tool schema。 +- OpenAI/Anthropic SDK 内建重试保持关闭,由可取消的 provider retry 包装器处理。 +- DNS `getaddrinfo` / `ENOTFOUND` / `EAI_AGAIN` 错误纳入 assistant 自动重试分类。 +- OAuth、新 provider 与完整动态模型目录仍属于既有裁剪范围,未在本轮扩展。 + ## 待办(下一轮) - [x] Anthropic provider(含 thinking 支持) diff --git a/packages/pi-ai/pyproject.toml b/packages/pi-ai/pyproject.toml index dc7ebae..9b6a47b 100644 --- a/packages/pi-ai/pyproject.toml +++ b/packages/pi-ai/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pi-ai" -version = "0.81.1" +version = "0.82.1" description = "Python port of @earendil-works/pi-ai — Unified LLM API with multi-provider streaming" readme = "README.md" license = { text = "MIT" } diff --git a/packages/pi-ai/src/pi_ai/__init__.py b/packages/pi-ai/src/pi_ai/__init__.py index bce13bc..acbce07 100644 --- a/packages/pi-ai/src/pi_ai/__init__.py +++ b/packages/pi-ai/src/pi_ai/__init__.py @@ -16,8 +16,8 @@ from __future__ import annotations -__version__ = "0.81.1" -__upstream_ref__ = "earendil-works/pi@v0.81.1" +__version__ = "0.82.1" +__upstream_ref__ = "earendil-works/pi@v0.82.1" # ---- 类型 ---- # ---- 事件流 ---- diff --git a/packages/pi-ai/src/pi_ai/constrained_sampling.py b/packages/pi-ai/src/pi_ai/constrained_sampling.py new file mode 100644 index 0000000..ea2dcfb --- /dev/null +++ b/packages/pi-ai/src/pi_ai/constrained_sampling.py @@ -0,0 +1,162 @@ +"""Provider-side constrained tool sampling. + +Port of upstream ``api/constrained-sampling.ts`` introduced in pi 0.82.0. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from .types import Tool + + +@dataclass(frozen=True) +class GrammarConstrainedSampling: + format: str + definition: str + input_property: str + + +@dataclass +class GrammarToolInputJsonBuffer: + input: str = "" + started: bool = False + closed: bool = False + + +def append_grammar_tool_input_json_delta( + buffer: GrammarToolInputJsonBuffer, + input_property: str, + next_input: str, + *, + close: bool, +) -> str | None: + if buffer.closed: + if close and next_input == buffer.input: + return None + raise ValueError( + f'grammar tool input for property "{input_property}" changed after it was closed' + ) + if not next_input.startswith(buffer.input): + raise ValueError( + f'grammar tool input for property "{input_property}" changed non-monotonically' + ) + input_delta = next_input[len(buffer.input) :] + if not close and not input_delta: + return None + delta = "" + if not buffer.started: + delta += f"{json.dumps(input_property)}:" + delta = "{" + delta + '"' + buffer.started = True + delta += json.dumps(input_delta)[1:-1] + buffer.input = next_input + if close: + delta += '"}' + buffer.closed = True + return delta + + +def _infer_grammar_input_property(tool: Tool) -> str: + schema = tool.parameters + if schema.get("type") != "object": + raise ValueError("grammar constrained sampling requires an object parameter schema") + required = schema.get("required") + if not isinstance(required, list) or len(required) != 1 or not isinstance(required[0], str): + raise ValueError( + "grammar constrained sampling requires exactly one required string property" + ) + input_property = required[0] + properties = schema.get("properties") + if not isinstance(properties, dict) or input_property not in properties: + raise ValueError( + f"grammar constrained sampling requires a properties entry for {input_property}" + ) + property_schema = properties[input_property] + if not isinstance(property_schema, dict) or property_schema.get("type") != "string": + raise ValueError( + f"grammar constrained sampling property {input_property} must have type string" + ) + return input_property + + +def resolve_json_schema_strict_sampling(tool: Tool, supports_strict_mode: bool) -> bool | None: + config = tool.constrained_sampling + if not isinstance(config, dict) or config.get("type") != "json_schema": + return None + if supports_strict_mode: + return True + if config.get("strict") == "require": + raise ValueError( + f'Tool "{tool.name}" requires JSON-schema constrained sampling, ' + "but strict tools are unsupported." + ) + return None + + +def resolve_grammar_constrained_sampling( + tool: Tool, supports_openai_grammar_tools: bool +) -> GrammarConstrainedSampling | None: + config = tool.constrained_sampling + if not isinstance(config, dict) or config.get("type") != "grammar": + return None + if not supports_openai_grammar_tools: + return None + + variants = config.get("variants") + variants = variants if isinstance(variants, dict) else {} + lark = variants.get("openai_lark") + regex = variants.get("openai_regex") + has_lark = isinstance(lark, str) and bool(lark.strip()) + has_regex = isinstance(regex, str) and bool(regex.strip()) + if not has_lark and not has_regex: + raise ValueError( + f'Tool "{tool.name}" cannot use grammar constrained sampling: ' + "no supported grammar variant was provided." + ) + try: + input_property = _infer_grammar_input_property(tool) + except ValueError as exc: + raise ValueError( + f'Tool "{tool.name}" cannot use grammar constrained sampling: {exc}.' + ) from exc + definition = lark if has_lark else regex + assert isinstance(definition, str) + return GrammarConstrainedSampling( + format="lark" if has_lark else "regex", + definition=definition, + input_property=input_property, + ) + + +def create_grammar_tool_input_properties( + tools: list[Tool] | None, supports_openai_grammar_tools: bool +) -> dict[str, str]: + properties: dict[str, str] = {} + for tool in tools or []: + grammar = resolve_grammar_constrained_sampling(tool, supports_openai_grammar_tools) + if grammar is not None: + properties[tool.name] = grammar.input_property + return properties + + +def get_grammar_tool_input(tool_name: str, arguments: dict[str, Any], input_property: str) -> str: + value = arguments.get(input_property) + if not isinstance(value, str): + raise ValueError( + f'Grammar tool call "{tool_name}" requires argument "{input_property}" to be a string.' + ) + return value + + +__all__ = [ + "GrammarConstrainedSampling", + "GrammarToolInputJsonBuffer", + "append_grammar_tool_input_json_delta", + "create_grammar_tool_input_properties", + "get_grammar_tool_input", + "resolve_grammar_constrained_sampling", + "resolve_json_schema_strict_sampling", +] diff --git a/packages/pi-ai/src/pi_ai/provider_retry.py b/packages/pi-ai/src/pi_ai/provider_retry.py new file mode 100644 index 0000000..1c8e393 --- /dev/null +++ b/packages/pi-ai/src/pi_ai/provider_retry.py @@ -0,0 +1,123 @@ +"""Abortable retries for provider SDK requests. + +The OpenAI and Anthropic clients are configured with ``max_retries=0`` and +wrapped here so retry backoff can be cancelled by the agent. +""" + +from __future__ import annotations + +import asyncio +import email.utils +import random +import time +from collections.abc import Awaitable, Callable, Mapping +from typing import TypeVar + +T = TypeVar("T") +DEFAULT_MAX_RETRY_DELAY_MS = 60_000 + + +def _headers(error: BaseException) -> Mapping[str, str]: + headers = getattr(error, "headers", None) + if headers is None: + response = getattr(error, "response", None) + headers = getattr(response, "headers", None) + return headers if isinstance(headers, Mapping) else {} + + +def _status(error: BaseException) -> int | None: + value = getattr(error, "status_code", getattr(error, "status", None)) + return value if isinstance(value, int) else None + + +def _header(headers: Mapping[str, str], name: str) -> str | None: + for key, value in headers.items(): + if key.lower() == name: + return str(value) + return None + + +def _is_retryable(error: BaseException) -> bool: + headers = _headers(error) + should_retry = _header(headers, "x-should-retry") + if should_retry == "true": + return True + if should_retry == "false": + return False + status = _status(error) + if status is None: + return hasattr(error, "status_code") or hasattr(error, "status") + return status in {408, 409, 429} or status >= 500 + + +def _validate_delay(delay_ms: float, maximum: int | None, message: str) -> float: + max_delay = DEFAULT_MAX_RETRY_DELAY_MS if maximum is None else maximum + if max_delay > 0 and delay_ms > max_delay: + raise RuntimeError( + f"Server requested {int((delay_ms + 999) // 1000)}s retry delay " + f"(max: {int((max_delay + 999) // 1000)}s). {message}" + ) + return max(0, delay_ms) + + +def _retry_delay_ms(error: BaseException, retry_index: int, maximum: int | None) -> float: + headers = _headers(error) + retry_after_ms = _header(headers, "retry-after-ms") + if retry_after_ms is not None: + try: + return _validate_delay(float(retry_after_ms), maximum, str(error)) + except ValueError: + pass + retry_after = _header(headers, "retry-after") + if retry_after is not None: + try: + delay = float(retry_after) * 1000 + except ValueError: + parsed = email.utils.parsedate_to_datetime(retry_after) + delay = float(parsed.timestamp()) * 1000 - time.time() * 1000 + return _validate_delay(delay, maximum, str(error)) + exponential = min(0.5 * (2**retry_index), 8) * 1000 + return float(exponential * (1 - random.random() * 0.25)) + + +async def _abortable_sleep(ms: float, cancel_event: asyncio.Event | None) -> None: + if cancel_event is None: + await asyncio.sleep(ms / 1000) + return + if cancel_event.is_set(): + raise asyncio.CancelledError + sleeper = asyncio.create_task(asyncio.sleep(ms / 1000)) + cancelled = asyncio.create_task(cancel_event.wait()) + done, pending = await asyncio.wait({sleeper, cancelled}, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + if cancelled in done: + raise asyncio.CancelledError + + +async def retry_provider_request( + request: Callable[[], Awaitable[T]], + *, + max_retries: int = 0, + max_retry_delay_ms: int | None = None, + cancel_event: asyncio.Event | None = None, +) -> T: + retries_remaining = max_retries + while True: + try: + return await request() + except asyncio.CancelledError: + raise + except Exception as error: + if cancel_event is not None and cancel_event.is_set(): + raise asyncio.CancelledError from error + if retries_remaining <= 0 or not _is_retryable(error): + raise + retry_index = max_retries - retries_remaining + retries_remaining -= 1 + await _abortable_sleep( + _retry_delay_ms(error, retry_index, max_retry_delay_ms), cancel_event + ) + + +__all__ = ["retry_provider_request"] diff --git a/packages/pi-ai/src/pi_ai/providers/anthropic_provider.py b/packages/pi-ai/src/pi_ai/providers/anthropic_provider.py index 8d83899..b4cbc89 100644 --- a/packages/pi-ai/src/pi_ai/providers/anthropic_provider.py +++ b/packages/pi-ai/src/pi_ai/providers/anthropic_provider.py @@ -23,6 +23,7 @@ from anthropic import AsyncAnthropic +from ..constrained_sampling import resolve_json_schema_strict_sampling from ..event_stream import EventStream from ..events import ( AssistantMessageEvent, @@ -39,6 +40,7 @@ ToolCallEndEvent, ToolCallStartEvent, ) +from ..provider_retry import retry_provider_request from ..types import ( AssistantMessage, Context, @@ -101,22 +103,27 @@ def _resolve_api_key() -> str: # ============================================================ -def _convert_tools(tools: list[Any]) -> list[dict[str, Any]]: +def _convert_tools( + tools: list[Any], *, supports_strict_tools: bool = False +) -> list[dict[str, Any]]: """ToolDef -> Anthropic tools 格式。input_schema 始终含 type/properties/required。""" out: list[dict[str, Any]] = [] for tool in tools: schema = tool.to_json_schema() if hasattr(tool, "to_json_schema") else tool.parameters - out.append( - { - "name": tool.name, - "description": tool.description, - "input_schema": { - "type": "object", - "properties": schema.get("properties", {}), - "required": schema.get("required", []), - }, - } - ) + strict = resolve_json_schema_strict_sampling(tool, supports_strict_tools) + legacy_schema = { + "type": "object", + "properties": schema.get("properties", {}), + "required": schema.get("required", []), + } + converted = { + "name": tool.name, + "description": tool.description, + "input_schema": {**schema, **legacy_schema} if strict else legacy_schema, + } + if strict: + converted["strict"] = True + out.append(converted) return out @@ -397,7 +404,10 @@ async def drive() -> None: if system: params["system"] = system if context.tools: - params["tools"] = _convert_tools(context.tools) + params["tools"] = _convert_tools( + context.tools, + supports_strict_tools=(model.compat or {}).get("supportsStrictTools", False), + ) if options and options.temperature is not None: params["temperature"] = options.temperature if options and options.timeout_ms is not None: @@ -414,7 +424,12 @@ def find_block_by_anthropic_idx(idx: int) -> _Block | None: return blocks.get(idx) try: - response = await client.messages.create(**params) + response = await retry_provider_request( + lambda: client.messages.create(**params), + max_retries=(options.max_retries or 0) if options else 0, + max_retry_delay_ms=options.max_retry_delay_ms if options else None, + cancel_event=options.cancel_event if options else None, + ) # 遍历类型化流事件 async for event in response: etype = event.type diff --git a/packages/pi-ai/src/pi_ai/providers/openai_provider.py b/packages/pi-ai/src/pi_ai/providers/openai_provider.py index 1ddf22d..3a223a7 100644 --- a/packages/pi-ai/src/pi_ai/providers/openai_provider.py +++ b/packages/pi-ai/src/pi_ai/providers/openai_provider.py @@ -23,6 +23,14 @@ from openai import APIConnectionError, APIStatusError, APITimeoutError, AsyncOpenAI +from ..constrained_sampling import ( + GrammarToolInputJsonBuffer, + append_grammar_tool_input_json_delta, + create_grammar_tool_input_properties, + get_grammar_tool_input, + resolve_grammar_constrained_sampling, + resolve_json_schema_strict_sampling, +) from ..event_stream import EventStream from ..events import ( AssistantMessageEvent, @@ -38,6 +46,7 @@ ToolCallEndEvent, ToolCallStartEvent, ) +from ..provider_retry import retry_provider_request from ..types import ( AssistantMessage, Context, @@ -82,13 +91,32 @@ class _ToolCallBlock: 仅解析期使用。 """ - __slots__ = ("tool_call", "partial_args", "stream_index", "content_index") + __slots__ = ( + "tool_call", + "partial_args", + "stream_index", + "content_index", + "custom_input_property", + "custom_input_buffer", + ) - def __init__(self, content_index: int, id: str = "", name: str = "") -> None: + def __init__( + self, + content_index: int, + id: str = "", + name: str = "", + custom_input_property: str | None = None, + ) -> None: self.tool_call = ToolCall(id=id, name=name, arguments={}) self.partial_args: str = "" self.stream_index: int | None = None self.content_index = content_index + self.custom_input_property = custom_input_property + self.custom_input_buffer = ( + GrammarToolInputJsonBuffer() if custom_input_property is not None else None + ) + if custom_input_property is not None: + self.tool_call.arguments = {custom_input_property: ""} # ============================================================ @@ -161,24 +189,51 @@ def _create_client( ) -def _convert_tools(tools: list[Any]) -> list[dict[str, Any]]: +def _convert_tools( + tools: list[Any], + *, + supports_strict_mode: bool = True, + supports_openai_grammar_tools: bool = False, +) -> list[dict[str, Any]]: """ToolDef 列表 -> OpenAI tools 格式。""" - return [ - { - "type": "function", - "function": { - "name": t.name, - "description": t.description, - "parameters": t.to_json_schema() if hasattr(t, "to_json_schema") else t.parameters, - "strict": False, - }, + converted: list[dict[str, Any]] = [] + for tool in tools: + grammar = resolve_grammar_constrained_sampling(tool, supports_openai_grammar_tools) + if grammar is not None: + converted.append( + { + "type": "custom", + "custom": { + "name": tool.name, + "description": tool.description, + "format": { + "type": "grammar", + "grammar": { + "syntax": grammar.format, + "definition": grammar.definition, + }, + }, + }, + } + ) + continue + strict = resolve_json_schema_strict_sampling(tool, supports_strict_mode) + function = { + "name": tool.name, + "description": tool.description, + "parameters": ( + tool.to_json_schema() if hasattr(tool, "to_json_schema") else tool.parameters + ), } - for t in tools - ] + if supports_strict_mode: + function["strict"] = strict if strict is not None else False + converted.append({"type": "function", "function": function}) + return converted def _convert_messages( context: Context, + grammar_tool_input_properties: dict[str, str] | None = None, ) -> tuple[list[dict[str, Any]], dict[str, str] | None]: """Context.messages -> OpenAI messages。返回 (messages, dev_headers)。""" out: list[dict[str, Any]] = [] @@ -209,16 +264,31 @@ def _convert_messages( tool_calls: list[dict[str, Any]] = [] for block in msg.__dict__["content"]: if isinstance(block, ToolCall): - tool_calls.append( - { - "id": block.id, - "type": "function", - "function": { - "name": block.name, - "arguments": json.dumps(block.arguments), - }, - } - ) + input_property = (grammar_tool_input_properties or {}).get(block.name) + if input_property is not None: + tool_calls.append( + { + "id": block.id, + "type": "custom", + "custom": { + "name": block.name, + "input": get_grammar_tool_input( + block.name, block.arguments, input_property + ), + }, + } + ) + else: + tool_calls.append( + { + "id": block.id, + "type": "function", + "function": { + "name": block.name, + "arguments": json.dumps(block.arguments), + }, + } + ) entry: dict[str, Any] = {"role": "assistant"} if text_parts: entry["content"] = "\n".join(text_parts) @@ -273,7 +343,12 @@ async def drive() -> None: client = _create_client(model, api_key, options.headers if options else None) # 构建请求参数 - messages, _ = _convert_messages(context) + compat = model.compat or {} + grammar_tool_input_properties = create_grammar_tool_input_properties( + context.tools, + compat.get("supportsOpenAIGrammarTools", False), + ) + messages, _ = _convert_messages(context, grammar_tool_input_properties) params: dict[str, Any] = { "model": model.id, "messages": messages, @@ -281,7 +356,11 @@ async def drive() -> None: "stream_options": {"include_usage": True}, } if context.tools: - params["tools"] = _convert_tools(context.tools) + params["tools"] = _convert_tools( + context.tools, + supports_strict_mode=compat.get("supportsStrictMode", True), + supports_openai_grammar_tools=compat.get("supportsOpenAIGrammarTools", False), + ) if options and options.temperature is not None: params["temperature"] = options.temperature if options and options.max_tokens is not None: @@ -300,7 +379,10 @@ async def drive() -> None: tool_blocks_by_id: dict[str, _ToolCallBlock] = {} def ensure_tool_block( - index: int | None, tool_id: str | None, func_name: str | None + index: int | None, + tool_id: str | None, + func_name: str | None, + custom_input_property: str | None = None, ) -> _ToolCallBlock: block = tool_blocks_by_index.get(index) if index is not None else None if block is None and tool_id: @@ -308,7 +390,12 @@ def ensure_tool_block( if block is None: # 新建:把 ToolCall 实例直接放进 content,记录其索引 cidx = len(output.content) - block = _ToolCallBlock(content_index=cidx, id=tool_id or "", name=func_name or "") + block = _ToolCallBlock( + content_index=cidx, + id=tool_id or "", + name=func_name or "", + custom_input_property=custom_input_property, + ) output.content.append(block.tool_call) if index is not None: block.stream_index = index @@ -327,7 +414,12 @@ def ensure_tool_block( return block try: - stream_obj = await client.chat.completions.create(**params) + stream_obj = await retry_provider_request( + lambda: client.chat.completions.create(**params), + max_retries=(options.max_retries or 0) if options else 0, + max_retry_delay_ms=options.max_retry_delay_ms if options else None, + cancel_event=options.cancel_event if options else None, + ) async for chunk in stream_obj: # usage(chunk 级) if chunk.usage: @@ -395,13 +487,39 @@ def ensure_tool_block( func = getattr(tc_delta, "function", None) func_name = getattr(func, "name", None) if func else None args_chunk = getattr(func, "arguments", None) if func else None + custom = getattr(tc_delta, "custom", None) + custom_name = getattr(custom, "name", None) if custom else None + custom_chunk = getattr(custom, "input", None) if custom else None + tool_name = func_name or custom_name + custom_property = ( + grammar_tool_input_properties.get(tool_name or "") + if custom is not None + else None + ) - block = ensure_tool_block(idx, tc_id, func_name) + block = ensure_tool_block( + idx, tc_id, tool_name, custom_input_property=custom_property + ) delta_str = "" if args_chunk: block.partial_args += args_chunk block.tool_call.arguments = _parse_streaming_json(block.partial_args) delta_str = args_chunk + elif custom_chunk and block.custom_input_property: + current = block.tool_call.arguments[block.custom_input_property] + next_input = str(current) + custom_chunk + buffer = block.custom_input_buffer + assert buffer is not None + delta_str = ( + append_grammar_tool_input_json_delta( + buffer, + block.custom_input_property, + next_input, + close=False, + ) + or "" + ) + block.tool_call.arguments = {block.custom_input_property: next_input} es.push( ToolCallDeltaEvent( content_index=block.content_index, @@ -428,6 +546,22 @@ def ensure_tool_block( ) ) for block in tool_blocks_by_index.values(): + if block.custom_input_property and block.custom_input_buffer: + input_value = str(block.tool_call.arguments[block.custom_input_property]) + closing_delta = append_grammar_tool_input_json_delta( + block.custom_input_buffer, + block.custom_input_property, + input_value, + close=True, + ) + if closing_delta: + es.push( + ToolCallDeltaEvent( + content_index=block.content_index, + delta=closing_delta, + partial=output, + ) + ) es.push( ToolCallEndEvent( content_index=block.content_index, diff --git a/packages/pi-ai/src/pi_ai/retry.py b/packages/pi-ai/src/pi_ai/retry.py index e8c6f98..059a663 100644 --- a/packages/pi-ai/src/pi_ai/retry.py +++ b/packages/pi-ai/src/pi_ai/retry.py @@ -64,6 +64,9 @@ "connection.?lost", "other side closed", "fetch failed", + "getaddrinfo", + "ENOTFOUND", + "EAI_AGAIN", "upstream.?connect", "reset before headers", "socket hang up", diff --git a/packages/pi-ai/src/pi_ai/types.py b/packages/pi-ai/src/pi_ai/types.py index b0b178b..a190a71 100644 --- a/packages/pi-ai/src/pi_ai/types.py +++ b/packages/pi-ai/src/pi_ai/types.py @@ -278,6 +278,9 @@ class Tool(BaseModel): name: str description: str parameters: dict[str, Any] = Field(default_factory=dict) + constrained_sampling: dict[str, Any] | Literal[False] | None = Field( + default=None, alias="constrainedSampling" + ) def to_json_schema(self) -> dict[str, Any]: """返回给 LLM 的 JSON Schema。""" @@ -372,6 +375,7 @@ class StreamOptions(BaseModel): timeout_ms: int | None = Field(default=None, alias="timeoutMs") max_retries: int | None = Field(default=None, alias="maxRetries") max_retry_delay_ms: int | None = Field(default=None, alias="maxRetryDelayMs") + cancel_event: Any = Field(default=None, alias="cancelEvent", exclude=True) metadata: dict[str, Any] | None = None env: dict[str, str] | None = None diff --git a/packages/pi-ai/tests/test_anthropic_logic.py b/packages/pi-ai/tests/test_anthropic_logic.py index 57b24ef..fad6191 100644 --- a/packages/pi-ai/tests/test_anthropic_logic.py +++ b/packages/pi-ai/tests/test_anthropic_logic.py @@ -199,6 +199,25 @@ def test_convert_tools_defaults(): assert result[0]["input_schema"] == {"type": "object", "properties": {}, "required": []} +def test_convert_tools_enables_strict_schema_for_capable_models(): + tool = Tool( + name="f", + description="d", + parameters={ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": False, + }, + constrained_sampling={"type": "json_schema", "strict": "require"}, + ) + + result = _convert_tools([tool], supports_strict_tools=True) + + assert result[0]["strict"] is True + assert result[0]["input_schema"]["additionalProperties"] is False + + # ============================================================ # thinking 配置 # ============================================================ diff --git a/packages/pi-ai/tests/test_constrained_sampling.py b/packages/pi-ai/tests/test_constrained_sampling.py new file mode 100644 index 0000000..dcf9b6d --- /dev/null +++ b/packages/pi-ai/tests/test_constrained_sampling.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import pytest + +from pi_ai import Tool +from pi_ai.constrained_sampling import ( + GrammarToolInputJsonBuffer, + append_grammar_tool_input_json_delta, + create_grammar_tool_input_properties, + resolve_grammar_constrained_sampling, + resolve_json_schema_strict_sampling, +) + + +def test_tool_accepts_camel_case_constrained_sampling(): + tool = Tool.model_validate( + { + "name": "answer", + "description": "Return an answer", + "parameters": {"type": "object", "properties": {}, "required": []}, + "constrainedSampling": {"type": "json_schema", "strict": "prefer"}, + } + ) + + assert tool.constrained_sampling is not None + assert tool.model_dump(by_alias=True)["constrainedSampling"]["strict"] == "prefer" + + +def test_preferred_strict_sampling_falls_back_when_unsupported(): + tool = Tool( + name="answer", + description="Return an answer", + parameters={"type": "object", "properties": {}, "required": []}, + constrained_sampling={"type": "json_schema", "strict": "prefer"}, + ) + + assert resolve_json_schema_strict_sampling(tool, supports_strict_mode=False) is None + + +def test_grammar_requires_exactly_one_required_string_property(): + tool = Tool( + name="bad", + description="Bad grammar tool", + parameters={ + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "string"}}, + "required": ["a", "b"], + }, + constrained_sampling={ + "type": "grammar", + "variants": {"openai_regex": ".+"}, + }, + ) + + with pytest.raises(ValueError, match="exactly one required string property"): + resolve_grammar_constrained_sampling(tool, supports_openai_grammar_tools=True) + + +def test_create_grammar_tool_input_properties_ignores_unsupported_provider(): + tool = Tool( + name="sql", + description="Generate SQL", + parameters={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + constrained_sampling={ + "type": "grammar", + "variants": {"openai_regex": "SELECT .*"}, + }, + ) + + assert create_grammar_tool_input_properties([tool], False) == {} + + +def test_grammar_tool_input_delta_forms_valid_incremental_json(): + buffer = GrammarToolInputJsonBuffer() + + first = append_grammar_tool_input_json_delta(buffer, "query", 'SELECT "a', close=False) + second = append_grammar_tool_input_json_delta(buffer, "query", 'SELECT "a"', close=True) + + assert first == '{"query":"SELECT \\"a' + assert second == '\\""}' diff --git a/packages/pi-ai/tests/test_openai_logic.py b/packages/pi-ai/tests/test_openai_logic.py index 63ee606..9e58570 100644 --- a/packages/pi-ai/tests/test_openai_logic.py +++ b/packages/pi-ai/tests/test_openai_logic.py @@ -7,9 +7,11 @@ import pytest -from pi_ai import Model, ModelCost +from pi_ai import AssistantMessage, Context, Model, ModelCost, Tool, ToolCall from pi_ai.providers.openai_provider import ( _STOP_REASON_MAP, + _convert_messages, + _convert_tools, _parse_chunk_usage, _parse_streaming_json, ) @@ -147,3 +149,92 @@ def test_stop_reason_mapping(): assert _STOP_REASON_MAP["tool_calls"] == "toolUse" assert _STOP_REASON_MAP["function_call"] == "toolUse" assert _STOP_REASON_MAP["content_filter"] == "error" + + +def test_convert_tools_enables_required_strict_json_schema(): + tool = Tool( + name="answer", + description="Return an answer", + parameters={ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": False, + }, + constrained_sampling={"type": "json_schema", "strict": "require"}, + ) + + converted = _convert_tools([tool], supports_strict_mode=True) + + assert converted[0]["function"]["strict"] is True + assert converted[0]["function"]["parameters"]["additionalProperties"] is False + + +def test_convert_tools_rejects_required_strict_when_unsupported(): + tool = Tool( + name="answer", + description="Return an answer", + parameters={"type": "object", "properties": {}, "required": []}, + constrained_sampling={"type": "json_schema", "strict": "require"}, + ) + + with pytest.raises(ValueError, match="requires JSON-schema constrained sampling"): + _convert_tools([tool], supports_strict_mode=False) + + +def test_convert_tools_uses_openai_lark_grammar(): + tool = Tool( + name="sql", + description="Generate SQL", + parameters={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + constrained_sampling={ + "type": "grammar", + "variants": { + "openai_lark": 'start: "SELECT 1"', + "openai_regex": "SELECT .*", + }, + }, + ) + + converted = _convert_tools([tool], supports_openai_grammar_tools=True) + + assert converted == [ + { + "type": "custom", + "custom": { + "name": "sql", + "description": "Generate SQL", + "format": { + "type": "grammar", + "grammar": {"syntax": "lark", "definition": 'start: "SELECT 1"'}, + }, + }, + } + ] + + +def test_convert_messages_replays_grammar_tool_as_custom_call(): + context = Context( + messages=[ + AssistantMessage( + content=[ToolCall(id="call-1", name="sql", arguments={"query": "SELECT 1"})], + api="openai-completions", + provider="openai", + model="gpt", + ) + ] + ) + + messages, _ = _convert_messages(context, {"sql": "query"}) + + assert messages[0]["tool_calls"] == [ + { + "id": "call-1", + "type": "custom", + "custom": {"name": "sql", "input": "SELECT 1"}, + } + ] diff --git a/packages/pi-ai/tests/test_provider_retry.py b/packages/pi-ai/tests/test_provider_retry.py new file mode 100644 index 0000000..87b9df5 --- /dev/null +++ b/packages/pi-ai/tests/test_provider_retry.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from pi_ai.provider_retry import retry_provider_request + + +class ProviderError(Exception): + def __init__(self, message: str, status: int | None, headers: dict[str, str] | None = None): + super().__init__(message) + self.status_code = status + self.headers = headers or {} + + +async def test_provider_retry_retries_transient_status(): + calls = 0 + + async def request(): + nonlocal calls + calls += 1 + if calls == 1: + raise ProviderError("busy", 503, {"retry-after-ms": "1"}) + return "ok" + + assert await retry_provider_request(request, max_retries=1) == "ok" + assert calls == 2 + + +async def test_provider_retry_honors_should_retry_false(): + async def request(): + raise ProviderError("no", 503, {"x-should-retry": "false"}) + + with pytest.raises(ProviderError): + await retry_provider_request(request, max_retries=3) + + +async def test_provider_retry_delay_is_abortable(): + cancel_event = asyncio.Event() + + async def request(): + cancel_event.set() + raise ProviderError("busy", 503, {"retry-after-ms": "1000"}) + + with pytest.raises(asyncio.CancelledError): + await retry_provider_request(request, max_retries=1, cancel_event=cancel_event) diff --git a/packages/pi-ai/tests/test_retry.py b/packages/pi-ai/tests/test_retry.py index 6c049c3..dd9557b 100644 --- a/packages/pi-ai/tests/test_retry.py +++ b/packages/pi-ai/tests/test_retry.py @@ -41,6 +41,8 @@ def _ok_msg() -> AssistantMessage: "ResourceExhausted", "timed out after 30000ms", "you can retry your request", + "getaddrinfo ENOTFOUND api.example.com", + "socket EAI_AGAIN api.example.com", ], ) def test_retryable_errors(msg): diff --git a/packages/pi-ai/tests/test_smoke.py b/packages/pi-ai/tests/test_smoke.py index 8aa5f4a..6e46840 100644 --- a/packages/pi-ai/tests/test_smoke.py +++ b/packages/pi-ai/tests/test_smoke.py @@ -4,5 +4,5 @@ def test_import() -> None: import pi_ai - assert pi_ai.__version__ - assert pi_ai.__upstream_ref__ == "earendil-works/pi@v0.81.1" + assert pi_ai.__version__ == "0.82.1" + assert pi_ai.__upstream_ref__ == "earendil-works/pi@v0.82.1" diff --git a/packages/pi-coding-agent/PORTING.md b/packages/pi-coding-agent/PORTING.md index 55e28da..ad2ec8f 100644 --- a/packages/pi-coding-agent/PORTING.md +++ b/packages/pi-coding-agent/PORTING.md @@ -1,6 +1,6 @@ # pi-coding-agent 移植注记 -对应上游:[`@earendil-works/pi-coding-agent`](https://github.com/earendil-works/pi/tree/main/packages/coding-agent)(v0.81.1) +对应上游:[`@earendil-works/pi-coding-agent`](https://github.com/earendil-works/pi/tree/main/packages/coding-agent)(v0.82.1) ## 有意偏离上游(重要:本包大幅裁剪) diff --git a/packages/pi-coding-agent/pyproject.toml b/packages/pi-coding-agent/pyproject.toml index 41151ef..82c7a8a 100644 --- a/packages/pi-coding-agent/pyproject.toml +++ b/packages/pi-coding-agent/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "pi-coding-agent" -version = "0.81.1" +version = "0.82.1" description = "Python port of @earendil-works/pi-coding-agent — Coding agent SDK (core only, no TUI)" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.11" authors = [{ name = "Justin Gao" }] dependencies = [ - "pi-ai>=0.81.1,<0.82", - "pi-agent-core>=0.81.1,<0.82", + "pi-ai>=0.82.1,<0.83", + "pi-agent-core>=0.82.1,<0.83", "pydantic>=2.7", "pyyaml>=6", ] diff --git a/packages/pi-coding-agent/src/pi_coding_agent/__init__.py b/packages/pi-coding-agent/src/pi_coding_agent/__init__.py index ffaaa1c..b7c1747 100644 --- a/packages/pi-coding-agent/src/pi_coding_agent/__init__.py +++ b/packages/pi-coding-agent/src/pi_coding_agent/__init__.py @@ -12,8 +12,8 @@ from __future__ import annotations -__version__ = "0.81.1" -__upstream_ref__ = "earendil-works/pi@v0.81.1" +__version__ = "0.82.1" +__upstream_ref__ = "earendil-works/pi@v0.82.1" # ---- 工具 ---- # ---- SDK 入口 ---- diff --git a/packages/pi-coding-agent/tests/test_smoke.py b/packages/pi-coding-agent/tests/test_smoke.py index 5e1f1a5..47830eb 100644 --- a/packages/pi-coding-agent/tests/test_smoke.py +++ b/packages/pi-coding-agent/tests/test_smoke.py @@ -4,5 +4,5 @@ def test_import() -> None: import pi_coding_agent - assert pi_coding_agent.__version__ - assert pi_coding_agent.__upstream_ref__ == "earendil-works/pi@v0.81.1" + assert pi_coding_agent.__version__ == "0.82.1" + assert pi_coding_agent.__upstream_ref__ == "earendil-works/pi@v0.82.1" diff --git a/packages/pi-server/PORTING.md b/packages/pi-server/PORTING.md index 03a9867..15f0cc3 100644 --- a/packages/pi-server/PORTING.md +++ b/packages/pi-server/PORTING.md @@ -1,6 +1,6 @@ # pi-server 移植注记 -对应上游:[`@earendil-works/pi-server`](https://github.com/earendil-works/pi/tree/main/packages/server)(v0.81.1) +对应上游:[`@earendil-works/pi-server`](https://github.com/earendil-works/pi/tree/main/packages/server)(v0.82.1) ## 有意偏离上游 diff --git a/packages/pi-server/pyproject.toml b/packages/pi-server/pyproject.toml index fbe4dc9..c2e085b 100644 --- a/packages/pi-server/pyproject.toml +++ b/packages/pi-server/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "pi-server" -version = "0.81.1" +version = "0.82.1" description = "Python port of @earendil-works/pi-server — Experimental agent server (Unix socket + JSONL)" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.11" authors = [{ name = "Justin Gao" }] dependencies = [ - "pi-coding-agent>=0.81.1,<0.82", + "pi-coding-agent>=0.82.1,<0.83", ] classifiers = [ diff --git a/packages/pi-server/src/pi_server/__init__.py b/packages/pi-server/src/pi_server/__init__.py index bab25e9..3899e1e 100644 --- a/packages/pi-server/src/pi_server/__init__.py +++ b/packages/pi-server/src/pi_server/__init__.py @@ -16,8 +16,8 @@ from __future__ import annotations -__version__ = "0.81.1" -__upstream_ref__ = "earendil-works/pi@v0.81.1" +__version__ = "0.82.1" +__upstream_ref__ = "earendil-works/pi@v0.82.1" from .config import get_server_dir, get_socket_path from .ipc import handle_request, send_request, serve diff --git a/packages/pi-server/tests/test_smoke.py b/packages/pi-server/tests/test_smoke.py index 6c2ea65..046a094 100644 --- a/packages/pi-server/tests/test_smoke.py +++ b/packages/pi-server/tests/test_smoke.py @@ -4,5 +4,5 @@ def test_import() -> None: import pi_server - assert pi_server.__version__ - assert pi_server.__upstream_ref__ == "earendil-works/pi@v0.81.1" + assert pi_server.__version__ == "0.82.1" + assert pi_server.__upstream_ref__ == "earendil-works/pi@v0.82.1" diff --git a/packages/pi-storage-sqlite/PORTING.md b/packages/pi-storage-sqlite/PORTING.md index ac5836d..c0ba947 100644 --- a/packages/pi-storage-sqlite/PORTING.md +++ b/packages/pi-storage-sqlite/PORTING.md @@ -1,6 +1,6 @@ # pi-storage-sqlite 移植注记 -对应上游:[`@earendil-works/pi-storage-sqlite-node`](https://github.com/earendil-works/pi/tree/main/packages/storage/sqlite-node)(v0.81.1) +对应上游:[`@earendil-works/pi-storage-sqlite-node`](https://github.com/earendil-works/pi/tree/main/packages/storage/sqlite-node)(v0.82.1) ## 有意偏离上游 diff --git a/packages/pi-storage-sqlite/pyproject.toml b/packages/pi-storage-sqlite/pyproject.toml index b56f961..4326c13 100644 --- a/packages/pi-storage-sqlite/pyproject.toml +++ b/packages/pi-storage-sqlite/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "pi-storage-sqlite" -version = "0.81.1" +version = "0.82.1" description = "Python port of @earendil-works/pi-storage-sqlite-node — SQLite session storage backend" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.11" authors = [{ name = "Justin Gao" }] dependencies = [ - "pi-ai>=0.81.1,<0.82", - "pi-agent-core>=0.81.1,<0.82", + "pi-ai>=0.82.1,<0.83", + "pi-agent-core>=0.82.1,<0.83", ] classifiers = [ diff --git a/packages/pi-storage-sqlite/src/pi_storage_sqlite/__init__.py b/packages/pi-storage-sqlite/src/pi_storage_sqlite/__init__.py index 39061af..6446dd5 100644 --- a/packages/pi-storage-sqlite/src/pi_storage_sqlite/__init__.py +++ b/packages/pi-storage-sqlite/src/pi_storage_sqlite/__init__.py @@ -7,8 +7,8 @@ from __future__ import annotations -__version__ = "0.81.1" -__upstream_ref__ = "earendil-works/pi@v0.81.1" +__version__ = "0.82.1" +__upstream_ref__ = "earendil-works/pi@v0.82.1" from .database import SqliteDatabase, SqliteRunResult, apply_migrations, open_database from .storage import SqliteSessionRepo, SqliteSessionStorage diff --git a/packages/pi-storage-sqlite/tests/test_smoke.py b/packages/pi-storage-sqlite/tests/test_smoke.py index 5d4ba50..2a1fe6d 100644 --- a/packages/pi-storage-sqlite/tests/test_smoke.py +++ b/packages/pi-storage-sqlite/tests/test_smoke.py @@ -4,5 +4,5 @@ def test_import() -> None: import pi_storage_sqlite - assert pi_storage_sqlite.__version__ - assert pi_storage_sqlite.__upstream_ref__ == "earendil-works/pi@v0.81.1" + assert pi_storage_sqlite.__version__ == "0.82.1" + assert pi_storage_sqlite.__upstream_ref__ == "earendil-works/pi@v0.82.1" diff --git a/uv.lock b/uv.lock index f893ddf..8489ba1 100644 --- a/uv.lock +++ b/uv.lock @@ -593,7 +593,7 @@ wheels = [ [[package]] name = "pi-agent-core" -version = "0.81.1" +version = "0.82.1" source = { editable = "packages/pi-agent-core" } dependencies = [ { name = "pi-ai" }, @@ -610,7 +610,7 @@ requires-dist = [ [[package]] name = "pi-ai" -version = "0.81.1" +version = "0.82.1" source = { editable = "packages/pi-ai" } dependencies = [ { name = "anthropic" }, @@ -631,7 +631,7 @@ requires-dist = [ [[package]] name = "pi-coding-agent" -version = "0.81.1" +version = "0.82.1" source = { editable = "packages/pi-coding-agent" } dependencies = [ { name = "pi-agent-core" }, @@ -693,7 +693,7 @@ dev = [ [[package]] name = "pi-server" -version = "0.81.1" +version = "0.82.1" source = { editable = "packages/pi-server" } dependencies = [ { name = "pi-coding-agent" }, @@ -704,7 +704,7 @@ requires-dist = [{ name = "pi-coding-agent", editable = "packages/pi-coding-agen [[package]] name = "pi-storage-sqlite" -version = "0.81.1" +version = "0.82.1" source = { editable = "packages/pi-storage-sqlite" } dependencies = [ { name = "pi-agent-core" },