Files
sorrow404null a7412824e0 chore: import original project baseline
Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
2026-09-17 16:40:01 +08:00

439 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
core/agent/stream_fn.py
=======================
🌟 pi-ai 流式接口的 Python 1:1 移植 —— OpenAI 兼容(含 vLLM
对照 pi-main 源码:
packages/ai/src/api/openai-completions.ts
- streamSimple() (行 469 起): 消费 SSE chunk → 产出 AssistantMessage
- buildParams() (行 536 起): 组装请求体
* vLLM/OpenAI 默认字段名 = max_completion_tokens
* 少数服务商(chutes/deepseek/moonshot 等)用 max_tokens
- mapStopReason(): finish_reason "length" → stopReason "length"
packages/ai/src/api/simple-options.ts
- buildBaseOptions(): maxTokens = options?.maxTokens ?? model.maxTokens
(即模型定义里的 maxTokens 一定会进入请求体,这就是它被发给 vLLM 的原因)
本文件职责:
1. to_openai_messages() —— pi 消息格式 → OpenAI API 格式(每轮调用前转换)
2. openai_stream() —— 流式请求,逐 chunk 产出 AssistantMessageEvent
结束产出最终 AgentMessage(含 usage / stop_reason / tool_calls
3. classify_error() —— 异常分类(对照 pi isRetryable 的输入)
🌟 修复 haocode 原有 P1 bugopenai 客户端带 timeout(原来无超时,
挂死的流会让线程永久阻塞)。max_retries=0(重试统一交给 recovery 层,
与 pi 一致:网络层不重试,会话层按 1600ms×1.6^n 退避重试)。
"""
from __future__ import annotations
import json
from typing import Any, Dict, Iterator, List, Optional, Tuple
from openai import OpenAI
from openai import APIConnectionError, APIStatusError, APITimeoutError, RateLimitError
from .types import (AgentError, AgentMessage, AssistantMessageEvent,
AbortSignal, ModelConfig, ToolCall, new_id)
# ======================================================================
# pi 消息 → OpenAI 消息(每轮发送前调用)
# ======================================================================
def to_openai_messages(context: List[AgentMessage],
pass_reasoning: bool = True) -> List[Dict[str, Any]]:
"""pi 消息 → OpenAI chat 格式。
🆕 thinking 模式(DeepSeek 等):assistant 消息必须把 reasoning_content
原样回传,否则服务端 400
'The reasoning_content in the thinking mode must be passed back to the API')。
默认开启:轮中续问/工具回灌场景,内存里本轮刚流式产出的 assistant 带
reasoning → 回传;DB 回放的旧行 reasoning=""(不入库)→ 无该字段,
跨轮全新请求不受影响。pass_reasoning=False 显式关闭(特殊网关逃生阀)。
"""
out: List[Dict[str, Any]] = []
for m in context:
if m.role == "system":
# 只放行请求头部的系统提示词(loop 注入);
# 历史中间出现的 system(DB 防御)仍然跳过
if not out:
out.append({"role": "system", "content": m.content})
continue
if m.role == "toolResult":
out.append({
"role": "tool",
"tool_call_id": m.tool_call_id,
"content": m.content if isinstance(m.content, str)
else "".join(c.get("text", "") for c in m.content
if isinstance(c, dict)),
})
continue
if m.role == "assistant":
entry: Dict[str, Any] = {"role": "assistant"}
# 纯工具调用轮:content 可能为空 → 用 ""vLLM 接受,避免 null 报错)
entry["content"] = m.content if m.content else ""
if m.tool_calls:
entry["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False)
if tc.arguments else tc.raw_arguments or "{}",
},
}
for tc in m.tool_calls
]
if pass_reasoning and m.reasoning:
entry["reasoning_content"] = m.reasoning
out.append(entry)
continue
# usercontent 可以是 str 或 OpenAI 多模态 list,原样透传)
out.append({"role": "user", "content": m.content})
return out
# ======================================================================
# 异常分类 —— 对照 pi isRetryable / retryable error 判定
# ======================================================================
def classify_error(e: Exception) -> AgentError:
"""把 openai SDK 异常分类,恢复层据此决定重试/放弃。"""
if isinstance(e, RateLimitError):
return AgentError(message=str(e), kind="rate_limit",
status_code=429, recoverable=True)
if isinstance(e, APITimeoutError):
return AgentError(message=f"请求超时: {e}", kind="timeout", recoverable=True)
if isinstance(e, APIConnectionError):
return AgentError(message=f"连接失败: {e}", kind="connection", recoverable=True)
if isinstance(e, APIStatusError):
code = e.status_code
msg = str(e)
if code in (502, 503, 504):
return AgentError(message=msg, kind="server_error",
status_code=code, recoverable=True)
if code == 529:
return AgentError(message=msg, kind="overload",
status_code=code, recoverable=True)
if code == 408:
return AgentError(message=msg, kind="timeout",
status_code=code, recoverable=True)
if code in (401, 403):
return AgentError(message=msg, kind="auth",
status_code=code, recoverable=False)
# 4xx(参数错误/模型不存在/上下文超长等)不可重试,交恢复层细分
return AgentError(message=msg, kind="unknown",
status_code=code, recoverable=False)
if isinstance(e, (ConnectionError, TimeoutError)):
return AgentError(message=str(e), kind="connection", recoverable=True)
return AgentError(message=str(e), kind="unknown", recoverable=False)
# ======================================================================
# 🆕 M1/M2: 流式字段提取(对照 pi openai-completions.ts 的字段优先级)
# ======================================================================
# 🆕 M1: 1:1 对照 pi chunk 级 reasoning 字段优先级(llama.cpp/vLLM/OpenRouter 各不同)
_REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
def _pick_reasoning(delta) -> str:
"""从 delta 上取第一个非空思考字段(只取一个,防同内容多字段重复计)"""
if delta is None:
return ""
dump = None
for name in _REASONING_FIELDS:
v = getattr(delta, name, None)
if v is None and dump is None and hasattr(delta, "model_dump"):
try:
dump = delta.model_dump()
except Exception:
dump = {}
if v is None and isinstance(dump, dict):
v = dump.get(name)
if v:
return str(v)
return ""
def _pick_usage(chunk):
"""🆕 M2: 对照 pi:先 chunk.usage,再 choices[0].usageMoonshot 系只放 choice 里)"""
u = getattr(chunk, "usage", None)
if u is not None:
return u
try:
choices = getattr(chunk, "choices", None)
if choices:
return getattr(choices[0], "usage", None)
except Exception:
pass
return None
# ======================================================================
# 流式请求核心 —— 对照 openai-completions.ts streamSimple
# ======================================================================
def openai_stream(context: List[AgentMessage],
model: ModelConfig,
signal: AbortSignal,
max_tokens: int,
tools: Optional[List[Any]] = None,
) -> Iterator[Tuple[str, Any]]:
"""
生成器协议(对照 pi streamSimple 的 for-await 消费方式):
yield ("event", AssistantMessageEvent) # 增量事件(text/thinking/toolcall/done
yield ("final", AgentMessage) # 最终助手消息(含 stop_reason/usage
异常统一转 AgentError 抛出(由循环层捕获 → stop_reason="error")。
🌟 中止语义与 pi 一致:每个 chunk 边界检查 signal.aborted
命中即关闭流、以 stop_reason="aborted" 收尾(不是 error)。
"""
client = OpenAI(
api_key=model.api_key or "EMPTY",
base_url=model.base_url,
# 🌟 P1 修复:显式超时(pi 侧由 fetch timeout 保证)
timeout=model.timeout_seconds,
max_retries=0, # 重试统一由 recovery 层负责
)
params: Dict[str, Any] = {
"model": model.name,
"messages": to_openai_messages(context, model.pass_reasoning),
"stream": True,
"stream_options": {"include_usage": True}, # 拿真实 usagepi 同款)
"temperature": model.temperature,
}
# 对照 pi buildParams:默认 max_completion_tokensvLLM 兼容)
if getattr(model, "use_max_tokens_field", False):
params["max_tokens"] = max_tokens
else:
params["max_completion_tokens"] = max_tokens
# 🌟 工具定义(对照 pi buildParams 的 tools 段)——不发给模型,
# 模型就不可能发出真正的 tool_call,只会用文字"演"工具调用!
if tools:
params["tools"] = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in tools
]
# 累积状态
content_parts: List[str] = []
reasoning_parts: List[str] = []
tc_acc: Dict[int, Dict[str, Any]] = {} # index -> {id, name, args}
finish_reason: Optional[str] = None
usage: Dict[str, Any] = {}
stream = None
try:
stream = client.chat.completions.create(**params)
for chunk in stream:
# 🌟 pi 同款:chunk 边界检查中止
if signal.aborted:
finish_reason = "aborted"
try:
stream.close()
except Exception:
pass
break
# 🆕 M2: usage 读取顺序对照 pi —— chunk.usage 优先,choice.usage 兜底
usage_obj = _pick_usage(chunk)
if usage_obj is not None:
usage = {
"input": getattr(usage_obj, "prompt_tokens", 0) or 0,
"output": getattr(usage_obj, "completion_tokens", 0) or 0,
"cacheRead": 0, "cacheWrite": 0,
}
if not getattr(chunk, "choices", None):
continue
choice = chunk.choices[0]
delta = choice.delta
# 🆕 M1: 思考增量(对照 pi reasoningFields 优先级,含多字段去重)
reasoning = _pick_reasoning(delta)
if reasoning:
yield ("event", AssistantMessageEvent.thinking_delta(reasoning))
reasoning_parts.append(reasoning)
if getattr(delta, "content", None):
yield ("event", AssistantMessageEvent.text_delta(delta.content))
content_parts.append(delta.content)
# 工具调用增量(按 index 累积 id/name/arguments 三段)
for tc_delta in (getattr(delta, "tool_calls", None) or []):
idx = tc_delta.index or 0
slot = tc_acc.setdefault(idx, {"id": "", "name": "", "args": ""})
if tc_delta.id:
slot["id"] = tc_delta.id
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, tc_delta.id, field="id"))
fn = tc_delta.function
if fn is not None:
if fn.name:
slot["name"] += fn.name
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, fn.name, field="name"))
if fn.arguments:
slot["args"] += fn.arguments
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, fn.arguments, field="arguments"))
if choice.finish_reason:
finish_reason = choice.finish_reason
except Exception as e:
# 中止过程中断网/断流不当错误处理(pi 同款语义)
if signal.aborted:
finish_reason = "aborted"
else:
raise classify_error(e)
finally:
try:
if stream is not None:
stream.close()
except Exception:
pass
# ---- 组装最终助手消息(对照 mapStopReason + AssistantMessage 构造)----
# pi mapStopReason: "length"→"length", "stop"/"tool_calls"→"stop", 其余→"stop"
if finish_reason == "aborted":
stop_reason = "aborted"
elif finish_reason == "length":
stop_reason = "length"
else:
stop_reason = "stop"
tool_calls: List[ToolCall] = []
for idx in sorted(tc_acc.keys()):
slot = tc_acc[idx]
try:
args = json.loads(slot["args"]) if slot["args"] else {}
except json.JSONDecodeError:
# 参数 JSON 被截断/损坏:保留 rawarguments 置空,
# 由循环层按「截断保护」路径处理(不执行残缺调用)
args = {}
tool_calls.append(ToolCall(
id=slot["id"] or new_id("call"),
name=slot["name"],
arguments=args,
raw_arguments=slot["args"],
))
final = AgentMessage(
role="assistant",
content="".join(content_parts),
reasoning="".join(reasoning_parts),
tool_calls=tool_calls,
stop_reason=stop_reason,
usage=usage,
)
yield ("event", AssistantMessageEvent.done())
yield ("final", final)
# ======================================================================
# OpenAI 格式 → pi 格式(from_openai_messages 的逆转换,用于从 DB 装载历史)
# ======================================================================
def _parse_tool_call(tc: Dict[str, Any]) -> Optional[ToolCall]:
"""
解析一条 OpenAI tool_call → ToolCall。
🌟 参数 JSON 可能残缺(历史里存了被截断的调用):
解析失败 → arguments={} 且 raw_arguments 兜底成合法 "{}"
保证 to_openai_messages 回发 API 时参数始终是合法 JSON
API 只校验 JSON 合法性 + tool_call_id 配对,不校验参数内容)。
"""
if not isinstance(tc, dict):
return None
fn = tc.get("function") or {}
raw_args = fn.get("arguments")
if not isinstance(raw_args, str):
raw_args = "" if raw_args is None else str(raw_args)
try:
args = json.loads(raw_args) if raw_args else {}
if not isinstance(args, dict):
args = {"_": args}
except (json.JSONDecodeError, TypeError, ValueError):
args = {}
raw_args = "{}" # 残缺 JSON 兜底,防回发 API 时 400
return ToolCall(
id=tc.get("id") or new_id("call"),
name=fn.get("name") or "",
arguments=args,
raw_arguments=raw_args or "{}",
)
def from_openai_messages(messages: List[Dict[str, Any]]) -> List[AgentMessage]:
"""
把 OpenAI chat 消息数组转成 pi 的 AgentMessage 列表(全保真)。
🌟 修复:worker 模式此前在此丢失全部工具历史(tool 消息被跳过、
assistant.tool_calls 不解析)→ 任务完成/中断后再次提问,模型看不到
之前读过什么文件、执行过什么命令。现改为无损转换:
- user: content 保持 str 或 OpenAI 多模态 list(原样透传)
- assistant: content + reasoning + tool_calls(纯工具轮保留 tool_calls
content 置 "",不再变成空 assistant 污染上下文)
- tool: → AgentMessage(role="toolResult", tool_call_id, content)
- system: 防御性跳过(DB 链表里不会出现)
下游 to_openai_messages 原生支持 toolResult / assistant.tool_calls
与 pi 语义一致,往返(to(from(x)))保真。
"""
out: List[AgentMessage] = []
for m in messages:
if not isinstance(m, dict):
continue
role = m.get("role")
content = m.get("content")
if role == "user":
if content is None:
content = ""
out.append(AgentMessage(role="user", content=content,
# 🆕 压缩持久化:跨轮携带 DB 行 id + 摘要 kind
# kind=compaction_summary → 二次压缩走迭代更新)
kind=m.get("_kind") or "",
db_msg_id=m.get("_db_msg_id") or "",
timestamp=int(m.get("timestamp") or 0)))
elif role == "assistant":
# tool_calls 解析(容忍缺失/损坏)
tool_calls: List[ToolCall] = []
for tc in (m.get("tool_calls") or []):
parsed = _parse_tool_call(tc)
if parsed is not None:
tool_calls.append(parsed)
out.append(AgentMessage(
role="assistant",
content=content if isinstance(content, str) else "",
reasoning=m.get("reasoning") or "",
tool_calls=tool_calls,
stop_reason="stop",
# 🆕 P1: 回放入库的 usage(锚点)+ timestampP0 时效校验)
usage=m.get("usage") or {},
timestamp=int(m.get("timestamp") or 0),
db_msg_id=m.get("_db_msg_id") or "",
))
elif role == "tool":
# tool 结果 → toolResult(保留 tool_call_id 供 API 配对)
if content is None:
content = ""
elif not isinstance(content, str):
content = str(content)
out.append(AgentMessage(
role="toolResult",
content=content,
tool_call_id=m.get("tool_call_id") or "",
timestamp=int(m.get("timestamp") or 0),
db_msg_id=m.get("_db_msg_id") or "",
))
# 其他 rolesystem 等)防御性跳过
return out