my-pi-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +318 -0
- package/package.json +45 -0
- package/pyproject.toml +50 -0
- package/src/my_agent_core/__init__.py +123 -0
- package/src/my_agent_core/agent.py +441 -0
- package/src/my_agent_core/background.py +121 -0
- package/src/my_agent_core/context.py +505 -0
- package/src/my_agent_core/events.py +153 -0
- package/src/my_agent_core/extensions/__init__.py +9 -0
- package/src/my_agent_core/extensions/core.py +197 -0
- package/src/my_agent_core/hooks.py +130 -0
- package/src/my_agent_core/loop.py +709 -0
- package/src/my_agent_core/main.py +134 -0
- package/src/my_agent_core/memory.py +241 -0
- package/src/my_agent_core/message_queue.py +110 -0
- package/src/my_agent_core/plugins.py +212 -0
- package/src/my_agent_core/registry.py +186 -0
- package/src/my_agent_core/session/__init__.py +79 -0
- package/src/my_agent_core/session/entries.py +197 -0
- package/src/my_agent_core/session/jsonl.py +60 -0
- package/src/my_agent_core/session/memory.py +137 -0
- package/src/my_agent_core/session/session.py +400 -0
- package/src/my_agent_core/session/storage.py +245 -0
- package/src/my_agent_core/session/store.py +131 -0
- package/src/my_agent_core/session/tree.py +86 -0
- package/src/my_agent_core/skills.py +149 -0
- package/src/my_agent_core/subagent_tasks.py +170 -0
- package/src/my_agent_core/subagents.py +148 -0
- package/src/my_agent_core/task_store.py +248 -0
- package/src/my_agent_core/tool_history.py +189 -0
- package/src/my_agent_core/tools/__init__.py +5 -0
- package/src/my_agent_core/tools/builtin/__init__.py +5 -0
- package/src/my_agent_core/tools/builtin/task.py +30 -0
- package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
- package/src/my_agent_core/tools/core.py +239 -0
- package/src/my_agent_llm/__init__.py +45 -0
- package/src/my_agent_llm/auth/__init__.py +46 -0
- package/src/my_agent_llm/auth/antigravity.py +209 -0
- package/src/my_agent_llm/auth/manager.py +259 -0
- package/src/my_agent_llm/auth/quota.py +56 -0
- package/src/my_agent_llm/auth/schema.py +94 -0
- package/src/my_agent_llm/client.py +116 -0
- package/src/my_agent_llm/config.py +17 -0
- package/src/my_agent_llm/events.py +84 -0
- package/src/my_agent_llm/models.py +195 -0
- package/src/my_agent_llm/providers/__init__.py +4 -0
- package/src/my_agent_llm/providers/_base.py +94 -0
- package/src/my_agent_llm/providers/anthropic.py +298 -0
- package/src/my_agent_llm/providers/antigravity.py +480 -0
- package/src/my_agent_llm/providers/deepseek.py +196 -0
- package/src/my_agent_llm/providers/openai.py +364 -0
- package/src/my_agent_llm/providers/registry.py +16 -0
- package/src/my_agent_llm/stream.py +218 -0
- package/src/my_coding_agent/__init__.py +66 -0
- package/src/my_coding_agent/agent.py +208 -0
- package/src/my_coding_agent/cli.py +78 -0
- package/src/my_coding_agent/file_reference.py +80 -0
- package/src/my_coding_agent/macro.py +408 -0
- package/src/my_coding_agent/mcp.py +243 -0
- package/src/my_coding_agent/mutation_queue.py +37 -0
- package/src/my_coding_agent/paths.py +119 -0
- package/src/my_coding_agent/permissions.py +84 -0
- package/src/my_coding_agent/prompt.py +54 -0
- package/src/my_coding_agent/rpc_server.py +2817 -0
- package/src/my_coding_agent/settings.py +126 -0
- package/src/my_coding_agent/tools/__init__.py +55 -0
- package/src/my_coding_agent/tools/base.py +58 -0
- package/src/my_coding_agent/tools/bash.py +206 -0
- package/src/my_coding_agent/tools/edit.py +226 -0
- package/src/my_coding_agent/tools/find.py +118 -0
- package/src/my_coding_agent/tools/grep.py +177 -0
- package/src/my_coding_agent/tools/ls.py +112 -0
- package/src/my_coding_agent/tools/read.py +113 -0
- package/src/my_coding_agent/tools/write.py +72 -0
- package/tui/README.md +27 -0
- package/tui/bin/my-agent.js +98 -0
- package/tui/dist/app.d.ts +41 -0
- package/tui/dist/app.js +110 -0
- package/tui/dist/bridge/event-translator.d.ts +92 -0
- package/tui/dist/bridge/event-translator.js +216 -0
- package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
- package/tui/dist/bridge/kernel-bridge.js +132 -0
- package/tui/dist/client.d.ts +63 -0
- package/tui/dist/client.js +239 -0
- package/tui/dist/components/assistant-message.d.ts +19 -0
- package/tui/dist/components/assistant-message.js +90 -0
- package/tui/dist/components/compaction-summary-message.d.ts +19 -0
- package/tui/dist/components/compaction-summary-message.js +46 -0
- package/tui/dist/components/custom-editor.d.ts +18 -0
- package/tui/dist/components/custom-editor.js +56 -0
- package/tui/dist/components/dynamic-border.d.ts +9 -0
- package/tui/dist/components/dynamic-border.js +14 -0
- package/tui/dist/components/footer.d.ts +39 -0
- package/tui/dist/components/footer.js +199 -0
- package/tui/dist/components/header.d.ts +4 -0
- package/tui/dist/components/header.js +21 -0
- package/tui/dist/components/keys.d.ts +5 -0
- package/tui/dist/components/keys.js +12 -0
- package/tui/dist/components/login-selector.d.ts +26 -0
- package/tui/dist/components/login-selector.js +181 -0
- package/tui/dist/components/logout-selector.d.ts +19 -0
- package/tui/dist/components/logout-selector.js +88 -0
- package/tui/dist/components/model-selector.d.ts +40 -0
- package/tui/dist/components/model-selector.js +268 -0
- package/tui/dist/components/session-selector.d.ts +54 -0
- package/tui/dist/components/session-selector.js +393 -0
- package/tui/dist/components/settings-selector.d.ts +24 -0
- package/tui/dist/components/settings-selector.js +146 -0
- package/tui/dist/components/status-indicator.d.ts +25 -0
- package/tui/dist/components/status-indicator.js +60 -0
- package/tui/dist/components/theme-selector.d.ts +14 -0
- package/tui/dist/components/theme-selector.js +77 -0
- package/tui/dist/components/thinking-selector.d.ts +21 -0
- package/tui/dist/components/thinking-selector.js +128 -0
- package/tui/dist/components/tool-execution.d.ts +31 -0
- package/tui/dist/components/tool-execution.js +206 -0
- package/tui/dist/components/tree-selector.d.ts +40 -0
- package/tui/dist/components/tree-selector.js +173 -0
- package/tui/dist/components/user-message-selector.d.ts +21 -0
- package/tui/dist/components/user-message-selector.js +103 -0
- package/tui/dist/components/user-message.d.ts +5 -0
- package/tui/dist/components/user-message.js +15 -0
- package/tui/dist/index.d.ts +11 -0
- package/tui/dist/index.js +11 -0
- package/tui/dist/interactive/chat-viewport.d.ts +19 -0
- package/tui/dist/interactive/chat-viewport.js +41 -0
- package/tui/dist/interactive/components.d.ts +1 -0
- package/tui/dist/interactive/components.js +1 -0
- package/tui/dist/interactive/interactive-mode.d.ts +89 -0
- package/tui/dist/interactive/interactive-mode.js +1625 -0
- package/tui/dist/interactive/theme.d.ts +1 -0
- package/tui/dist/interactive/theme.js +1 -0
- package/tui/dist/interactive/tui-renderer.d.ts +8 -0
- package/tui/dist/interactive/tui-renderer.js +10 -0
- package/tui/dist/protocol.d.ts +78 -0
- package/tui/dist/protocol.js +1 -0
- package/tui/dist/theme/dark.json +54 -0
- package/tui/dist/theme/light.json +71 -0
- package/tui/dist/theme/theme.d.ts +20 -0
- package/tui/dist/theme/theme.js +86 -0
- package/tui/package.json +25 -0
|
@@ -0,0 +1,2817 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, TextIO
|
|
16
|
+
|
|
17
|
+
from dotenv import find_dotenv, load_dotenv
|
|
18
|
+
|
|
19
|
+
from my_agent_core.events import (
|
|
20
|
+
AgentEnd,
|
|
21
|
+
AgentStart,
|
|
22
|
+
ContextCompacted,
|
|
23
|
+
Event,
|
|
24
|
+
MessageEnd,
|
|
25
|
+
MessageStart,
|
|
26
|
+
MessageUpdate,
|
|
27
|
+
ToolExecutionEnd,
|
|
28
|
+
ToolExecutionStart,
|
|
29
|
+
ToolExecutionUpdate,
|
|
30
|
+
ToolsChanged,
|
|
31
|
+
TurnEnd,
|
|
32
|
+
TurnStart,
|
|
33
|
+
)
|
|
34
|
+
from my_agent_core.session import Session, SessionInfoEntry
|
|
35
|
+
from my_agent_core.session.entries import (
|
|
36
|
+
BranchSummaryEntry,
|
|
37
|
+
CompactionEntry,
|
|
38
|
+
LabelEntry,
|
|
39
|
+
LeafEntry,
|
|
40
|
+
MessageEntry,
|
|
41
|
+
ModelChangeEntry,
|
|
42
|
+
ThinkingLevelChangeEntry,
|
|
43
|
+
)
|
|
44
|
+
from my_agent_core.session.tree import lowest_common_ancestor
|
|
45
|
+
from my_agent_core.skills import SkillManager
|
|
46
|
+
from my_agent_core.tool_history import repair_tool_history
|
|
47
|
+
from my_agent_llm import LLM, Config, Message
|
|
48
|
+
from my_agent_llm.auth.manager import AuthManager
|
|
49
|
+
from my_agent_llm.auth.schema import ApiKeyCredential, OAuthCredential
|
|
50
|
+
from my_coding_agent.agent import CodingAgent
|
|
51
|
+
from my_coding_agent.macro import MacroEngine
|
|
52
|
+
from my_coding_agent.paths import AgentPaths
|
|
53
|
+
from my_coding_agent.permissions import PermissionGate
|
|
54
|
+
from my_coding_agent.prompt import build_default_coding_prompt
|
|
55
|
+
from my_coding_agent.settings import Settings, load_settings, save_settings
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
if sys.platform == "win32":
|
|
60
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
61
|
+
reconfigure_fn = getattr(stream, "reconfigure", None)
|
|
62
|
+
if callable(reconfigure_fn):
|
|
63
|
+
reconfigure_fn(encoding="utf-8", errors="replace")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def uuid7_str() -> str:
|
|
67
|
+
"""生成符合 RFC 9562 规范的 UUIDv7 字符串(基于毫秒时间戳保序)。"""
|
|
68
|
+
try:
|
|
69
|
+
timestamp_ms = int(time.time() * 1000)
|
|
70
|
+
except Exception:
|
|
71
|
+
timestamp_ms = 0
|
|
72
|
+
rand = int.from_bytes(os.urandom(10), "big")
|
|
73
|
+
uuid_int = (
|
|
74
|
+
((timestamp_ms & 0xFFFFFFFFFFFF) << 80)
|
|
75
|
+
| (0x7 << 76)
|
|
76
|
+
| (((rand >> 62) & 0x0FFF) << 64)
|
|
77
|
+
| (0x2 << 62)
|
|
78
|
+
| (rand & 0x3FFFFFFFFFFFFFFF)
|
|
79
|
+
)
|
|
80
|
+
return str(uuid.UUID(int=uuid_int))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def serialize_message(m: Message) -> dict[str, Any]:
|
|
84
|
+
"""将内部 Message 实体转为标准 JSON 字典,保留 role、content 与关键 metadata (tool_calls / tool_call_id 等)。"""
|
|
85
|
+
md: dict[str, Any] = {}
|
|
86
|
+
if m.metadata:
|
|
87
|
+
for k, v in m.metadata.items():
|
|
88
|
+
if k == "tool_calls" and isinstance(v, list):
|
|
89
|
+
serialized_tcs = []
|
|
90
|
+
for tc in v:
|
|
91
|
+
if hasattr(tc, "model_dump"):
|
|
92
|
+
serialized_tcs.append(tc.model_dump())
|
|
93
|
+
elif isinstance(tc, dict):
|
|
94
|
+
serialized_tcs.append(tc)
|
|
95
|
+
else:
|
|
96
|
+
serialized_tcs.append(str(tc))
|
|
97
|
+
md[k] = serialized_tcs
|
|
98
|
+
elif hasattr(v, "model_dump"):
|
|
99
|
+
md[k] = v.model_dump()
|
|
100
|
+
elif isinstance(v, (str, int, float, bool, list, dict)) or v is None:
|
|
101
|
+
md[k] = v
|
|
102
|
+
else:
|
|
103
|
+
md[k] = str(v)
|
|
104
|
+
return {
|
|
105
|
+
"role": m.role,
|
|
106
|
+
"content": m.content,
|
|
107
|
+
"metadata": md,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def serialize_event(event: Event, stats: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
112
|
+
"""将 Python 内部不可变事实事件序列化为对标 Pi AgentEvent 规范的 JSON 字典。"""
|
|
113
|
+
if isinstance(event, AgentStart):
|
|
114
|
+
return {
|
|
115
|
+
"type": "agent_start",
|
|
116
|
+
"system_prompt": event.system_prompt,
|
|
117
|
+
"user_input": event.user_input,
|
|
118
|
+
}
|
|
119
|
+
elif isinstance(event, AgentEnd):
|
|
120
|
+
res: dict[str, Any] = {
|
|
121
|
+
"type": "agent_end",
|
|
122
|
+
"iterations": event.iterations,
|
|
123
|
+
"stop_reason": event.stop_reason,
|
|
124
|
+
"final_text": event.final_text or "",
|
|
125
|
+
}
|
|
126
|
+
if stats:
|
|
127
|
+
res.update(stats)
|
|
128
|
+
return res
|
|
129
|
+
elif isinstance(event, TurnStart):
|
|
130
|
+
return {
|
|
131
|
+
"type": "turn_start",
|
|
132
|
+
"iteration": event.iteration,
|
|
133
|
+
}
|
|
134
|
+
elif isinstance(event, TurnEnd):
|
|
135
|
+
res = {
|
|
136
|
+
"type": "turn_end",
|
|
137
|
+
}
|
|
138
|
+
if stats:
|
|
139
|
+
res.update(stats)
|
|
140
|
+
return res
|
|
141
|
+
elif isinstance(event, MessageStart):
|
|
142
|
+
msg = event.message
|
|
143
|
+
return {
|
|
144
|
+
"type": "message_start",
|
|
145
|
+
"message": {
|
|
146
|
+
"role": msg.role,
|
|
147
|
+
"content": msg.content or "",
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
elif isinstance(event, MessageUpdate):
|
|
151
|
+
msg = event.message
|
|
152
|
+
chunk: Any = event.chunk
|
|
153
|
+
delta_text = ""
|
|
154
|
+
delta_thinking = ""
|
|
155
|
+
if chunk is not None:
|
|
156
|
+
delta_text = getattr(chunk, "content", None) or getattr(chunk, "text", "") or ""
|
|
157
|
+
reasoning = getattr(chunk, "reasoning_content", None)
|
|
158
|
+
if not reasoning and getattr(chunk, "metadata", None) and isinstance(chunk.metadata, dict):
|
|
159
|
+
reasoning = chunk.metadata.get("reasoning_content")
|
|
160
|
+
delta_thinking = str(reasoning) if reasoning else ""
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
"type": "message_update",
|
|
164
|
+
"message": {
|
|
165
|
+
"role": msg.role,
|
|
166
|
+
"content": msg.content or "",
|
|
167
|
+
},
|
|
168
|
+
"delta": delta_text,
|
|
169
|
+
"reasoning_delta": delta_thinking,
|
|
170
|
+
}
|
|
171
|
+
elif isinstance(event, MessageEnd):
|
|
172
|
+
msg = event.message
|
|
173
|
+
meta = getattr(msg, "metadata", None) or {}
|
|
174
|
+
usage = meta.get("usage")
|
|
175
|
+
out: dict[str, Any] = {
|
|
176
|
+
"type": "message_end",
|
|
177
|
+
"message": {
|
|
178
|
+
"role": msg.role,
|
|
179
|
+
"content": msg.content or "",
|
|
180
|
+
"metadata": meta,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
if usage:
|
|
184
|
+
out["usage"] = usage
|
|
185
|
+
if stats:
|
|
186
|
+
out.update(stats)
|
|
187
|
+
return out
|
|
188
|
+
elif isinstance(event, ToolExecutionStart):
|
|
189
|
+
return {
|
|
190
|
+
"type": "tool_execution_start",
|
|
191
|
+
"toolCallId": event.tool_call_id,
|
|
192
|
+
"toolName": event.tool_name,
|
|
193
|
+
"args": event.args,
|
|
194
|
+
}
|
|
195
|
+
elif isinstance(event, ToolExecutionUpdate):
|
|
196
|
+
return {
|
|
197
|
+
"type": "tool_execution_update",
|
|
198
|
+
"toolCallId": event.tool_call_id,
|
|
199
|
+
"toolName": event.tool_name,
|
|
200
|
+
"partialResult": event.partial_result,
|
|
201
|
+
}
|
|
202
|
+
elif isinstance(event, ToolExecutionEnd):
|
|
203
|
+
return {
|
|
204
|
+
"type": "tool_execution_end",
|
|
205
|
+
"toolCallId": event.tool_call_id,
|
|
206
|
+
"toolName": event.tool_name,
|
|
207
|
+
"result": event.result,
|
|
208
|
+
"isError": event.is_error,
|
|
209
|
+
}
|
|
210
|
+
elif isinstance(event, ContextCompacted):
|
|
211
|
+
return {
|
|
212
|
+
"type": "context_compacted",
|
|
213
|
+
"tokensBefore": event.tokens_before,
|
|
214
|
+
"tokensAfter": event.tokens_after,
|
|
215
|
+
"summarizedCount": event.summarized_count,
|
|
216
|
+
}
|
|
217
|
+
elif isinstance(event, ToolsChanged):
|
|
218
|
+
return {
|
|
219
|
+
"type": "tools_changed",
|
|
220
|
+
"action": event.action,
|
|
221
|
+
"name": event.name,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {"type": type(event).__name__.lower()}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def resolve_model_context_window(model_name: str) -> int:
|
|
228
|
+
"""归一化解析模型的实际最大上下文窗口大小(Tokens)。"""
|
|
229
|
+
m = (model_name or "").lower()
|
|
230
|
+
if m.startswith("gemini-"):
|
|
231
|
+
return 1048576
|
|
232
|
+
if "opus" in m:
|
|
233
|
+
return 250000
|
|
234
|
+
if "sonnet" in m:
|
|
235
|
+
return 200000
|
|
236
|
+
if "gpt-4o" in m:
|
|
237
|
+
return 128000
|
|
238
|
+
if "deepseek" in m:
|
|
239
|
+
return 1000000 if ("v4" in m or "flash" in m) else 64000
|
|
240
|
+
return 128000
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
KNOWN_MODEL_CATALOG: list[dict[str, Any]] = [
|
|
244
|
+
# OpenAI
|
|
245
|
+
{"id": "gpt-4o", "provider": "openai", "name": "GPT-4o", "contextWindow": 128000},
|
|
246
|
+
{"id": "gpt-4o-mini", "provider": "openai", "name": "GPT-4o mini", "contextWindow": 128000},
|
|
247
|
+
{"id": "o1", "provider": "openai", "name": "o1", "contextWindow": 200000},
|
|
248
|
+
{"id": "o3-mini", "provider": "openai", "name": "o3-mini", "contextWindow": 200000},
|
|
249
|
+
# Anthropic
|
|
250
|
+
{"id": "claude-3-5-sonnet-20241022", "provider": "anthropic", "name": "Claude 3.5 Sonnet", "contextWindow": 200000},
|
|
251
|
+
{"id": "claude-3-5-haiku-20241022", "provider": "anthropic", "name": "Claude 3.5 Haiku", "contextWindow": 200000},
|
|
252
|
+
{"id": "claude-3-opus-20240229", "provider": "anthropic", "name": "Claude 3 Opus", "contextWindow": 200000},
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def discover_deepseek_models_remote(
|
|
257
|
+
base_url: str = "https://api.deepseek.com", timeout: float = 5.0
|
|
258
|
+
) -> list[dict[str, Any]]:
|
|
259
|
+
"""主动向 DeepSeek 官方或兼容端点的 /models 接口发起探测,实时获取当前账户可用的最新模型列表。"""
|
|
260
|
+
try:
|
|
261
|
+
import httpx
|
|
262
|
+
from my_agent_llm.auth.manager import AuthManager
|
|
263
|
+
|
|
264
|
+
paths = AgentPaths()
|
|
265
|
+
auth_mgr = AuthManager(auth_path=paths.auth_path)
|
|
266
|
+
cred = auth_mgr.get_credential("deepseek")
|
|
267
|
+
api_key = None
|
|
268
|
+
if isinstance(cred, ApiKeyCredential):
|
|
269
|
+
api_key = cred.resolve_key()
|
|
270
|
+
elif isinstance(cred, OAuthCredential):
|
|
271
|
+
api_key = cred.access
|
|
272
|
+
if not api_key:
|
|
273
|
+
api_key = os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
|
274
|
+
if not api_key:
|
|
275
|
+
return []
|
|
276
|
+
|
|
277
|
+
target_base = os.environ.get("OPENAI_BASE_URL") or base_url
|
|
278
|
+
target_base_clean = target_base.rstrip("/")
|
|
279
|
+
models_url = f"{target_base_clean}/models"
|
|
280
|
+
|
|
281
|
+
res = httpx.get(
|
|
282
|
+
models_url,
|
|
283
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
284
|
+
timeout=timeout,
|
|
285
|
+
)
|
|
286
|
+
if res.status_code != 200:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
data = res.json()
|
|
290
|
+
models_data = data.get("data", [])
|
|
291
|
+
if not isinstance(models_data, list) or not models_data:
|
|
292
|
+
return []
|
|
293
|
+
|
|
294
|
+
models: list[dict[str, Any]] = []
|
|
295
|
+
for item in models_data:
|
|
296
|
+
m_id = item.get("id")
|
|
297
|
+
if not m_id:
|
|
298
|
+
continue
|
|
299
|
+
ctx = 1000000 if ("v4" in m_id or "flash" in m_id) else 64000
|
|
300
|
+
name = m_id
|
|
301
|
+
if m_id == "deepseek-chat":
|
|
302
|
+
name = "DeepSeek-V3"
|
|
303
|
+
elif m_id == "deepseek-reasoner":
|
|
304
|
+
name = "DeepSeek-R1"
|
|
305
|
+
elif m_id == "deepseek-flash":
|
|
306
|
+
name = "DeepSeek-Flash"
|
|
307
|
+
elif m_id == "deepseek-v4-pro":
|
|
308
|
+
name = "DeepSeek-V4 Pro"
|
|
309
|
+
|
|
310
|
+
models.append(
|
|
311
|
+
{
|
|
312
|
+
"id": m_id,
|
|
313
|
+
"provider": "deepseek",
|
|
314
|
+
"name": name,
|
|
315
|
+
"contextWindow": ctx,
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if models:
|
|
320
|
+
cache_file = Path.home() / ".my-pi-agent" / "deepseek-model-catalog.json"
|
|
321
|
+
try:
|
|
322
|
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
try:
|
|
324
|
+
checked_at_ts = int(time.time() * 1000)
|
|
325
|
+
except Exception:
|
|
326
|
+
checked_at_ts = 0
|
|
327
|
+
cache_data = {"version": 1, "checkedAt": checked_at_ts, "models": models}
|
|
328
|
+
cache_file.write_text(json.dumps(cache_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
329
|
+
except Exception as exc:
|
|
330
|
+
logger.debug("写入 deepseek 缓存异常: %s", exc)
|
|
331
|
+
|
|
332
|
+
return models
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
logger.debug("DeepSeek 远程动态模型探测失败: %s", exc)
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_deepseek_catalog(force: bool = False) -> list[dict[str, Any]]:
|
|
339
|
+
"""纯动态获取 DeepSeek 模型目录(带 4 小时本地缓存与远程动态探测)。"""
|
|
340
|
+
try:
|
|
341
|
+
now_ms = int(time.time() * 1000)
|
|
342
|
+
except Exception:
|
|
343
|
+
now_ms = 0
|
|
344
|
+
|
|
345
|
+
cache_file = Path.home() / ".my-pi-agent" / "deepseek-model-catalog.json"
|
|
346
|
+
if cache_file.exists():
|
|
347
|
+
try:
|
|
348
|
+
raw_text = cache_file.read_text(encoding="utf-8")
|
|
349
|
+
data = json.loads(raw_text)
|
|
350
|
+
checked_at = data.get("checkedAt", 0)
|
|
351
|
+
if not force and checked_at > 0 and (now_ms - checked_at < 4 * 60 * 60 * 1000):
|
|
352
|
+
cached = data.get("models", [])
|
|
353
|
+
if cached:
|
|
354
|
+
return cached
|
|
355
|
+
except Exception as exc:
|
|
356
|
+
logger.debug("读取本地 deepseek 缓存异常: %s", exc)
|
|
357
|
+
|
|
358
|
+
remote = discover_deepseek_models_remote()
|
|
359
|
+
if remote:
|
|
360
|
+
return remote
|
|
361
|
+
|
|
362
|
+
# 仅当完全没有网络且没有本地缓存时的静态离线兜底
|
|
363
|
+
return [
|
|
364
|
+
{"id": "deepseek-chat", "provider": "deepseek", "name": "DeepSeek-V3", "contextWindow": 64000},
|
|
365
|
+
{"id": "deepseek-reasoner", "provider": "deepseek", "name": "DeepSeek-R1", "contextWindow": 64000},
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _antigravity_model_rank(model_id: str) -> tuple[int, int, str]:
|
|
370
|
+
"""对标 pi-antigravity grouping.ts 的 comparePublicModels 优先级排序。"""
|
|
371
|
+
mid = model_id.lower()
|
|
372
|
+
version = 0
|
|
373
|
+
m = re.match(r"^gemini-(\d+)(?:\.(\d+))?", mid)
|
|
374
|
+
if m:
|
|
375
|
+
try:
|
|
376
|
+
v_major = int(m.group(1))
|
|
377
|
+
v_minor = int(m.group(2) or 0)
|
|
378
|
+
version = v_major * 1000 + v_minor
|
|
379
|
+
except (ValueError, TypeError):
|
|
380
|
+
version = 0
|
|
381
|
+
|
|
382
|
+
if "flash" in mid and "pro" not in mid:
|
|
383
|
+
return (0, -version, mid)
|
|
384
|
+
if mid.startswith("claude-opus"):
|
|
385
|
+
return (1, 0, mid)
|
|
386
|
+
if mid.startswith("claude-sonnet"):
|
|
387
|
+
return (2, 0, mid)
|
|
388
|
+
if mid.startswith("claude-"):
|
|
389
|
+
return (3, 0, mid)
|
|
390
|
+
if "pro" in mid:
|
|
391
|
+
return (4, -version, mid)
|
|
392
|
+
if mid.startswith("gemini-"):
|
|
393
|
+
return (5, -version, mid)
|
|
394
|
+
if mid.startswith("gpt-oss"):
|
|
395
|
+
return (6, 0, mid)
|
|
396
|
+
return (7, 0, mid)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
ANTIGRAVITY_CACHE_TTL_MS = 4 * 60 * 60 * 1000 # 4 小时刷新一次,对标 pi-antigravity
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def discover_antigravity_models_remote(timeout: float = 6.0) -> list[dict[str, Any]]:
|
|
403
|
+
"""主动向 Google Cloud Code Assist 专有接口发起 fetchAvailableModels 探测,并按 pi-antigravity 规范完成规约折叠。"""
|
|
404
|
+
try:
|
|
405
|
+
import httpx
|
|
406
|
+
from my_agent_llm.auth.antigravity import ANTIGRAVITY_USER_AGENT, AntigravityAuthResolver
|
|
407
|
+
|
|
408
|
+
resolver = AntigravityAuthResolver()
|
|
409
|
+
creds = resolver.resolve_credentials()
|
|
410
|
+
if not creds:
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
headers = {
|
|
414
|
+
"Authorization": f"Bearer {creds.access_token}",
|
|
415
|
+
"Content-Type": "application/json",
|
|
416
|
+
"User-Agent": ANTIGRAVITY_USER_AGENT,
|
|
417
|
+
}
|
|
418
|
+
body = {"project": creds.project_id}
|
|
419
|
+
|
|
420
|
+
endpoints = [
|
|
421
|
+
"https://daily-cloudcode-pa.googleapis.com",
|
|
422
|
+
"https://cloudcode-pa.googleapis.com",
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
raw_models: dict[str, Any] = {}
|
|
426
|
+
for ep in endpoints:
|
|
427
|
+
try:
|
|
428
|
+
res = httpx.post(
|
|
429
|
+
f"{ep}/v1internal:fetchAvailableModels",
|
|
430
|
+
headers=headers,
|
|
431
|
+
json=body,
|
|
432
|
+
timeout=timeout,
|
|
433
|
+
)
|
|
434
|
+
if res.status_code == 200:
|
|
435
|
+
raw_models = res.json().get("models", {})
|
|
436
|
+
if raw_models:
|
|
437
|
+
break
|
|
438
|
+
except Exception:
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
if not raw_models:
|
|
442
|
+
return []
|
|
443
|
+
|
|
444
|
+
# 归约折叠算法 (严格对齐 pi-antigravity 的 grouping.ts)
|
|
445
|
+
runtime_aliases = {
|
|
446
|
+
"gemini-3-flash-agent": "gemini-3.5-flash",
|
|
447
|
+
"gemini-pro-agent": "gemini-3.1-pro",
|
|
448
|
+
}
|
|
449
|
+
thinking_suffixes = [
|
|
450
|
+
"-extra-low",
|
|
451
|
+
"-extra-high",
|
|
452
|
+
"-thinking",
|
|
453
|
+
"-minimal",
|
|
454
|
+
"-medium",
|
|
455
|
+
"-high",
|
|
456
|
+
"-low",
|
|
457
|
+
"-tiered",
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
public_groups: dict[str, dict[str, Any]] = {}
|
|
461
|
+
for runtime_id, info in raw_models.items():
|
|
462
|
+
if not re.match(r"^(gemini-|claude-|gpt-oss-)", runtime_id, re.I):
|
|
463
|
+
continue
|
|
464
|
+
if (
|
|
465
|
+
any(runtime_id.startswith(p) for p in ["chat_", "tab_", "MODEL_"])
|
|
466
|
+
or "image" in runtime_id
|
|
467
|
+
or "2.5" in runtime_id
|
|
468
|
+
):
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
public_id = runtime_aliases.get(runtime_id)
|
|
472
|
+
if not public_id:
|
|
473
|
+
cleaned = runtime_id
|
|
474
|
+
for sfx in thinking_suffixes:
|
|
475
|
+
if cleaned.endswith(sfx):
|
|
476
|
+
cleaned = cleaned[: -len(sfx)]
|
|
477
|
+
break
|
|
478
|
+
public_id = cleaned
|
|
479
|
+
|
|
480
|
+
if public_id not in public_groups:
|
|
481
|
+
display_name = info.get("displayName") or public_id
|
|
482
|
+
clean_name = re.sub(
|
|
483
|
+
r"\s*\((?:extra\s*low|extra\s*high|low|medium|high|minimal|thinking)\)\s*$",
|
|
484
|
+
"",
|
|
485
|
+
display_name,
|
|
486
|
+
flags=re.I,
|
|
487
|
+
).strip()
|
|
488
|
+
ctx_window = (
|
|
489
|
+
1048576
|
|
490
|
+
if public_id.startswith("gemini-")
|
|
491
|
+
else (250000 if "opus" in public_id else (200000 if "sonnet" in public_id else 131072))
|
|
492
|
+
)
|
|
493
|
+
public_groups[public_id] = {
|
|
494
|
+
"id": public_id,
|
|
495
|
+
"provider": "antigravity",
|
|
496
|
+
"name": f"{clean_name} (Antigravity)" if "Antigravity" not in clean_name else clean_name,
|
|
497
|
+
"contextWindow": ctx_window,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
models = list(public_groups.values())
|
|
501
|
+
models.sort(key=lambda x: _antigravity_model_rank(x["id"]))
|
|
502
|
+
|
|
503
|
+
# 写入持久化缓存
|
|
504
|
+
cache_file = Path.home() / ".my-pi-agent" / "antigravity-model-catalog.json"
|
|
505
|
+
try:
|
|
506
|
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
try:
|
|
508
|
+
checked_at_ts = int(time.time() * 1000)
|
|
509
|
+
except Exception:
|
|
510
|
+
checked_at_ts = 0
|
|
511
|
+
cache_data = {
|
|
512
|
+
"version": 1,
|
|
513
|
+
"checkedAt": checked_at_ts,
|
|
514
|
+
"models": models,
|
|
515
|
+
}
|
|
516
|
+
cache_file.write_text(json.dumps(cache_data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
logger.debug("写入本地 antigravity-model-catalog.json 异常: %s", exc)
|
|
519
|
+
|
|
520
|
+
return models
|
|
521
|
+
except Exception as exc:
|
|
522
|
+
logger.debug("Antigravity 远程动态模型探测失败: %s", exc)
|
|
523
|
+
return []
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def get_antigravity_catalog(force: bool = False) -> list[dict[str, Any]]:
|
|
527
|
+
"""动态获取对标 pi-antigravity 的公共模型目录。
|
|
528
|
+
|
|
529
|
+
1. 优先检查本地缓存(~/.my-pi-agent/ 或 ~/.pi/agent/),若在 4 小时有效期内且未强制刷新,直接返回;
|
|
530
|
+
2. 若缓存缺失或已过期,主动发起远程 fetchAvailableModels 探测并更新缓存;
|
|
531
|
+
3. 若网络或探测失败,回退至 pi-antigravity 官方 ANTIGRAVITY_MODELS 权威静态表。
|
|
532
|
+
"""
|
|
533
|
+
try:
|
|
534
|
+
now_ms = int(time.time() * 1000)
|
|
535
|
+
except Exception:
|
|
536
|
+
now_ms = 0
|
|
537
|
+
|
|
538
|
+
# 1. 优先从本地缓存加载 (4小时TTL)
|
|
539
|
+
for cache_path in [
|
|
540
|
+
Path.home() / ".my-pi-agent" / "antigravity-model-catalog.json",
|
|
541
|
+
Path.home() / ".pi" / "agent" / "antigravity-model-catalog.json",
|
|
542
|
+
]:
|
|
543
|
+
if cache_path.exists():
|
|
544
|
+
try:
|
|
545
|
+
raw_text = cache_path.read_text(encoding="utf-8")
|
|
546
|
+
data = json.loads(raw_text)
|
|
547
|
+
checked_at = data.get("checkedAt", 0)
|
|
548
|
+
if not force and checked_at > 0 and (now_ms - checked_at < ANTIGRAVITY_CACHE_TTL_MS):
|
|
549
|
+
models = []
|
|
550
|
+
seen = set()
|
|
551
|
+
for m in data.get("models", []):
|
|
552
|
+
mid = m.get("id")
|
|
553
|
+
if not mid or mid in seen:
|
|
554
|
+
continue
|
|
555
|
+
if (
|
|
556
|
+
any(mid.startswith(p) for p in ["chat_", "tab_", "MODEL_"])
|
|
557
|
+
or "image" in mid
|
|
558
|
+
or "2.5" in mid
|
|
559
|
+
):
|
|
560
|
+
continue
|
|
561
|
+
seen.add(mid)
|
|
562
|
+
models.append(
|
|
563
|
+
{
|
|
564
|
+
"id": mid,
|
|
565
|
+
"provider": "antigravity",
|
|
566
|
+
"name": m.get("name") or f"{mid} (Antigravity)",
|
|
567
|
+
"contextWindow": m.get("contextWindow", 1048576),
|
|
568
|
+
}
|
|
569
|
+
)
|
|
570
|
+
if models:
|
|
571
|
+
models.sort(key=lambda x: _antigravity_model_rank(x["id"]))
|
|
572
|
+
return models
|
|
573
|
+
except Exception as exc:
|
|
574
|
+
logger.debug("读取本地缓存 %s 异常: %s", cache_path, exc)
|
|
575
|
+
|
|
576
|
+
# 2. 尝试远程动态探测
|
|
577
|
+
remote_models = discover_antigravity_models_remote()
|
|
578
|
+
if remote_models:
|
|
579
|
+
return remote_models
|
|
580
|
+
|
|
581
|
+
# 3. pi-antigravity 官方 ANTIGRAVITY_MODELS 权威静态表
|
|
582
|
+
fallback = [
|
|
583
|
+
{
|
|
584
|
+
"id": "gemini-3.8-flash",
|
|
585
|
+
"provider": "antigravity",
|
|
586
|
+
"name": "Gemini 3.8 Flash (Antigravity)",
|
|
587
|
+
"contextWindow": 1048576,
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
"id": "gemini-3.7-flash",
|
|
591
|
+
"provider": "antigravity",
|
|
592
|
+
"name": "Gemini 3.7 Flash (Antigravity)",
|
|
593
|
+
"contextWindow": 1048576,
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
"id": "gemini-3.6-flash",
|
|
597
|
+
"provider": "antigravity",
|
|
598
|
+
"name": "Gemini 3.6 Flash (Antigravity)",
|
|
599
|
+
"contextWindow": 1048576,
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
"id": "gemini-3.5-flash",
|
|
603
|
+
"provider": "antigravity",
|
|
604
|
+
"name": "Gemini 3.5 Flash (Antigravity)",
|
|
605
|
+
"contextWindow": 1048576,
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
"id": "gemini-3.1-pro",
|
|
609
|
+
"provider": "antigravity",
|
|
610
|
+
"name": "Gemini 3.1 Pro (Antigravity)",
|
|
611
|
+
"contextWindow": 1048576,
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
"id": "claude-opus-4-6",
|
|
615
|
+
"provider": "antigravity",
|
|
616
|
+
"name": "Claude Opus 4.6 (Antigravity)",
|
|
617
|
+
"contextWindow": 250000,
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
"id": "claude-sonnet-4-6",
|
|
621
|
+
"provider": "antigravity",
|
|
622
|
+
"name": "Claude Sonnet 4.6 (Antigravity)",
|
|
623
|
+
"contextWindow": 200000,
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
"id": "gpt-oss-120b",
|
|
627
|
+
"provider": "antigravity",
|
|
628
|
+
"name": "GPT-OSS 120B (Antigravity)",
|
|
629
|
+
"contextWindow": 131072,
|
|
630
|
+
},
|
|
631
|
+
]
|
|
632
|
+
fallback.sort(key=lambda x: _antigravity_model_rank(x["id"]))
|
|
633
|
+
return fallback
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class RpcServer:
|
|
637
|
+
"""标准 stdio JSON-RPC 2.0 服务端,将 Python 无头 CodingAgent 连接至 Node 前端。"""
|
|
638
|
+
|
|
639
|
+
def __init__(
|
|
640
|
+
self,
|
|
641
|
+
stdin: TextIO | None = None,
|
|
642
|
+
stdout: TextIO | None = None,
|
|
643
|
+
stderr: TextIO | None = None,
|
|
644
|
+
agent: CodingAgent | None = None,
|
|
645
|
+
llm: Any | None = None,
|
|
646
|
+
paths: AgentPaths | None = None,
|
|
647
|
+
):
|
|
648
|
+
self.stdin = stdin or sys.stdin
|
|
649
|
+
self.stdout = stdout or sys.stdout
|
|
650
|
+
self.stderr = stderr or sys.stderr
|
|
651
|
+
self.agent = agent
|
|
652
|
+
self.llm = llm
|
|
653
|
+
self.paths = paths
|
|
654
|
+
self.macro_engine: MacroEngine = MacroEngine(
|
|
655
|
+
workspace=agent.workspace if agent else None,
|
|
656
|
+
paths=paths,
|
|
657
|
+
)
|
|
658
|
+
self.auth_mgr: AuthManager | None = AuthManager(auth_path=paths.auth_path) if paths else None
|
|
659
|
+
self.settings: Settings | None = None
|
|
660
|
+
self.is_shutting_down = False
|
|
661
|
+
self._write_lock = threading.Lock()
|
|
662
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
663
|
+
self.session_usage: dict[str, Any] = {
|
|
664
|
+
"input": 0,
|
|
665
|
+
"output": 0,
|
|
666
|
+
"cacheRead": 0,
|
|
667
|
+
"cacheWrite": 0,
|
|
668
|
+
"total": 0,
|
|
669
|
+
"cost": 0.0,
|
|
670
|
+
"latestCacheHitRate": 0.0,
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
def emit_json(self, payload: dict[str, Any]) -> None:
|
|
674
|
+
"""向 stdout 写入单行 JSON 并强制 flush。"""
|
|
675
|
+
line = json.dumps(payload, ensure_ascii=False)
|
|
676
|
+
with self._write_lock:
|
|
677
|
+
try:
|
|
678
|
+
self.stdout.write(line + "\n")
|
|
679
|
+
self.stdout.flush()
|
|
680
|
+
except UnicodeEncodeError:
|
|
681
|
+
# 编码兜底:若当前宿主 stdout 不支持特定 Unicode 字符,使用 ASCII 转义输出
|
|
682
|
+
ascii_line = json.dumps(payload, ensure_ascii=True)
|
|
683
|
+
self.stdout.write(ascii_line + "\n")
|
|
684
|
+
self.stdout.flush()
|
|
685
|
+
|
|
686
|
+
def send_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
687
|
+
"""向客户端发送单向通知 (如 event)。"""
|
|
688
|
+
self.emit_json(
|
|
689
|
+
{
|
|
690
|
+
"jsonrpc": "2.0",
|
|
691
|
+
"method": method,
|
|
692
|
+
"params": params,
|
|
693
|
+
}
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
def send_response(
|
|
697
|
+
self,
|
|
698
|
+
req_id: int | str,
|
|
699
|
+
result: Any = None,
|
|
700
|
+
error: dict[str, Any] | None = None,
|
|
701
|
+
) -> dict[str, Any]:
|
|
702
|
+
"""构造并发送 RPC 响应,同时返回字典便于单元测试断言。"""
|
|
703
|
+
resp: dict[str, Any] = {
|
|
704
|
+
"jsonrpc": "2.0",
|
|
705
|
+
"id": req_id,
|
|
706
|
+
}
|
|
707
|
+
if error is not None:
|
|
708
|
+
resp["error"] = error
|
|
709
|
+
else:
|
|
710
|
+
resp["result"] = result or {}
|
|
711
|
+
|
|
712
|
+
self.emit_json(resp)
|
|
713
|
+
return resp
|
|
714
|
+
|
|
715
|
+
def _compute_session_usage(self, session: Session, model_name: str) -> dict[str, Any]:
|
|
716
|
+
"""对标 Pi 规范,从会话历史中提取所有 Assistant 消息的 usage 累加统计。"""
|
|
717
|
+
totals: dict[str, Any] = {
|
|
718
|
+
"input": 0,
|
|
719
|
+
"output": 0,
|
|
720
|
+
"cacheRead": 0,
|
|
721
|
+
"cacheWrite": 0,
|
|
722
|
+
"total": 0,
|
|
723
|
+
"cost": 0.0,
|
|
724
|
+
"latestCacheHitRate": 0.0,
|
|
725
|
+
}
|
|
726
|
+
for msg in session.get_full_history_messages():
|
|
727
|
+
if msg.role == "assistant" and msg.metadata:
|
|
728
|
+
usage = msg.metadata.get("usage")
|
|
729
|
+
if usage and isinstance(usage, dict):
|
|
730
|
+
prompt_tok = usage.get("prompt_tokens") or usage.get("input") or 0
|
|
731
|
+
comp_tok = usage.get("completion_tokens") or usage.get("output") or 0
|
|
732
|
+
cache_read = (
|
|
733
|
+
usage.get("cache_read_tokens") or usage.get("cache_read") or usage.get("cacheRead") or 0
|
|
734
|
+
)
|
|
735
|
+
cache_write = (
|
|
736
|
+
usage.get("cache_write_tokens") or usage.get("cache_write") or usage.get("cacheWrite") or 0
|
|
737
|
+
)
|
|
738
|
+
total_tok = usage.get("total_tokens") or usage.get("total") or (prompt_tok + comp_tok)
|
|
739
|
+
|
|
740
|
+
totals["input"] += prompt_tok
|
|
741
|
+
totals["output"] += comp_tok
|
|
742
|
+
totals["cacheRead"] += cache_read
|
|
743
|
+
totals["cacheWrite"] += cache_write
|
|
744
|
+
totals["total"] += total_tok
|
|
745
|
+
|
|
746
|
+
total_prompt = prompt_tok + cache_read + cache_write
|
|
747
|
+
if total_prompt > 0 and cache_read > 0:
|
|
748
|
+
totals["latestCacheHitRate"] = (cache_read / total_prompt) * 100.0
|
|
749
|
+
|
|
750
|
+
model_lower = (model_name or "").lower()
|
|
751
|
+
if "gemini" in model_lower:
|
|
752
|
+
totals["cost"] += (prompt_tok * 0.1 + comp_tok * 0.4 + cache_read * 0.025) / 1000000.0
|
|
753
|
+
elif "claude" in model_lower:
|
|
754
|
+
if "opus" in model_lower:
|
|
755
|
+
totals["cost"] += (prompt_tok * 15.0 + comp_tok * 75.0 + cache_read * 1.5) / 1000000.0
|
|
756
|
+
else:
|
|
757
|
+
totals["cost"] += (prompt_tok * 3.0 + comp_tok * 15.0 + cache_read * 0.3) / 1000000.0
|
|
758
|
+
elif "deepseek" in model_lower:
|
|
759
|
+
totals["cost"] += (prompt_tok * 0.14 + comp_tok * 0.28 + cache_read * 0.014) / 1000000.0
|
|
760
|
+
elif "gpt-4o" in model_lower:
|
|
761
|
+
totals["cost"] += (prompt_tok * 2.5 + comp_tok * 10.0 + cache_read * 1.25) / 1000000.0
|
|
762
|
+
|
|
763
|
+
# 对标 Pi 原厂 calculateContextTokens:提取最后一次推理生效的 Context 大小
|
|
764
|
+
last_context_tokens = 0
|
|
765
|
+
history_msgs = session.get_full_history_messages()
|
|
766
|
+
for msg in reversed(history_msgs):
|
|
767
|
+
if msg.role == "assistant" and msg.metadata:
|
|
768
|
+
usage = msg.metadata.get("usage")
|
|
769
|
+
if usage and isinstance(usage, dict):
|
|
770
|
+
prompt_tok = usage.get("prompt_tokens") or usage.get("input") or 0
|
|
771
|
+
comp_tok = usage.get("completion_tokens") or usage.get("output") or 0
|
|
772
|
+
cache_read = (
|
|
773
|
+
usage.get("cache_read_tokens") or usage.get("cache_read") or usage.get("cacheRead") or 0
|
|
774
|
+
)
|
|
775
|
+
cache_write = (
|
|
776
|
+
usage.get("cache_write_tokens") or usage.get("cache_write") or usage.get("cacheWrite") or 0
|
|
777
|
+
)
|
|
778
|
+
tot = prompt_tok + comp_tok + cache_read + cache_write
|
|
779
|
+
if tot > 0:
|
|
780
|
+
last_context_tokens = tot
|
|
781
|
+
break
|
|
782
|
+
|
|
783
|
+
if not last_context_tokens and history_msgs:
|
|
784
|
+
total_chars = sum(len(m.content or "") for m in history_msgs)
|
|
785
|
+
last_context_tokens = max(1, total_chars // 4)
|
|
786
|
+
|
|
787
|
+
totals["contextTokens"] = last_context_tokens
|
|
788
|
+
totals["cacheHitRate"] = totals["latestCacheHitRate"]
|
|
789
|
+
|
|
790
|
+
return totals
|
|
791
|
+
|
|
792
|
+
def _resolve_initial_llm(
|
|
793
|
+
self,
|
|
794
|
+
workspace_path: Path,
|
|
795
|
+
explicit_model: str | None,
|
|
796
|
+
settings: Settings,
|
|
797
|
+
auth_mgr: AuthManager,
|
|
798
|
+
) -> LLM | None:
|
|
799
|
+
"""根据启动参数、工作区配置与凭证中心探测构造底层 LLM 客户端。"""
|
|
800
|
+
model_name = explicit_model or settings.default_model
|
|
801
|
+
provider = None
|
|
802
|
+
if model_name and "/" in model_name:
|
|
803
|
+
provider, model_name = model_name.split("/", 1)
|
|
804
|
+
elif model_name and model_name.startswith("gemini-"):
|
|
805
|
+
provider = "antigravity"
|
|
806
|
+
elif model_name and ("deepseek" in model_name):
|
|
807
|
+
provider = (
|
|
808
|
+
"deepseek"
|
|
809
|
+
if (os.environ.get("DEEPSEEK_API_KEY") or auth_mgr.get_credential("deepseek"))
|
|
810
|
+
else ("openai" if explicit_model else None)
|
|
811
|
+
)
|
|
812
|
+
elif model_name and ("gpt-" in model_name or "o1" in model_name or "o3" in model_name):
|
|
813
|
+
provider = "openai"
|
|
814
|
+
elif model_name and ("claude-" in model_name):
|
|
815
|
+
provider = "anthropic"
|
|
816
|
+
|
|
817
|
+
api_key = None
|
|
818
|
+
base_url = None
|
|
819
|
+
if not provider:
|
|
820
|
+
if os.environ.get("OPENAI_API_KEY") or auth_mgr.get_credential("openai"):
|
|
821
|
+
provider = "openai"
|
|
822
|
+
model_name = explicit_model or os.environ.get("OPENAI_MODEL") or "gpt-4o"
|
|
823
|
+
elif os.environ.get("DEEPSEEK_API_KEY") or auth_mgr.get_credential("deepseek"):
|
|
824
|
+
provider = "deepseek"
|
|
825
|
+
model_name = explicit_model or os.environ.get("DEEPSEEK_MODEL") or "deepseek-chat"
|
|
826
|
+
elif os.environ.get("ANTHROPIC_API_KEY") or auth_mgr.get_credential("anthropic"):
|
|
827
|
+
provider = "anthropic"
|
|
828
|
+
model_name = explicit_model or os.environ.get("ANTHROPIC_MODEL") or "claude-3-5-sonnet-20241022"
|
|
829
|
+
else:
|
|
830
|
+
from my_agent_llm.auth.antigravity import AntigravityAuthResolver
|
|
831
|
+
|
|
832
|
+
resolver = AntigravityAuthResolver(workspace=workspace_path)
|
|
833
|
+
if resolver.resolve_credentials() is not None or auth_mgr.get_credential("antigravity") is not None:
|
|
834
|
+
provider = "antigravity"
|
|
835
|
+
model_name = explicit_model or "gemini-3.8-flash"
|
|
836
|
+
else:
|
|
837
|
+
provider = settings.default_provider or "openai"
|
|
838
|
+
model_name = explicit_model or "gpt-4o"
|
|
839
|
+
|
|
840
|
+
if provider:
|
|
841
|
+
# 1. 优先从全局凭据中心 (~/.my-pi-agent/auth.json) 加载
|
|
842
|
+
cred = auth_mgr.get_credential(provider)
|
|
843
|
+
if cred is not None:
|
|
844
|
+
if isinstance(cred, ApiKeyCredential):
|
|
845
|
+
api_key = cred.resolve_key()
|
|
846
|
+
if not base_url and cred.base_url:
|
|
847
|
+
base_url = cred.base_url
|
|
848
|
+
elif isinstance(cred, OAuthCredential):
|
|
849
|
+
api_key = cred.access
|
|
850
|
+
|
|
851
|
+
# 2. 次选本地环境变量与 .env 兜底
|
|
852
|
+
if not api_key:
|
|
853
|
+
api_key = os.environ.get(f"{provider.upper()}_API_KEY")
|
|
854
|
+
base_url = os.environ.get(f"{provider.upper()}_BASE_URL")
|
|
855
|
+
if provider == "openai":
|
|
856
|
+
api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
857
|
+
base_url = base_url or os.environ.get("OPENAI_BASE_URL")
|
|
858
|
+
elif provider == "antigravity":
|
|
859
|
+
api_key = (
|
|
860
|
+
api_key or os.environ.get("ANTIGRAVITY_ACCESS_TOKEN") or os.environ.get("GOOGLE_ACCESS_TOKEN")
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
if not api_key and provider != "antigravity":
|
|
864
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
865
|
+
if not base_url and provider == "openai":
|
|
866
|
+
base_url = os.environ.get("OPENAI_BASE_URL")
|
|
867
|
+
|
|
868
|
+
try:
|
|
869
|
+
return LLM(config=Config(provider=provider, model=model_name, api_key=api_key, base_url=base_url))
|
|
870
|
+
except Exception:
|
|
871
|
+
return None
|
|
872
|
+
|
|
873
|
+
async def _handle_initialize(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
874
|
+
workspace_path = Path(params.get("workspace", ".")).resolve()
|
|
875
|
+
paths = self.paths or AgentPaths()
|
|
876
|
+
paths.ensure_directories()
|
|
877
|
+
self.paths = paths
|
|
878
|
+
settings = load_settings(paths, cwd=workspace_path)
|
|
879
|
+
self.settings = settings
|
|
880
|
+
auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
|
|
881
|
+
self.auth_mgr = auth_mgr
|
|
882
|
+
|
|
883
|
+
explicit_model = params.get("model")
|
|
884
|
+
mode = params.get("mode", settings.default_permission_mode)
|
|
885
|
+
|
|
886
|
+
if (workspace_path / ".env").exists():
|
|
887
|
+
load_dotenv(workspace_path / ".env", override=False)
|
|
888
|
+
|
|
889
|
+
llm = self.llm
|
|
890
|
+
if llm is None:
|
|
891
|
+
llm = self._resolve_initial_llm(workspace_path, explicit_model, settings, auth_mgr)
|
|
892
|
+
|
|
893
|
+
session_file = paths.default_session_path(workspace_path)
|
|
894
|
+
session_file.parent.mkdir(parents=True, exist_ok=True)
|
|
895
|
+
|
|
896
|
+
target_session: Session | None = None
|
|
897
|
+
should_continue = bool(params.get("continue_session", False) or params.get("continue", False))
|
|
898
|
+
resume_param = params.get("resume")
|
|
899
|
+
if resume_param and isinstance(resume_param, str) and resume_param != "true":
|
|
900
|
+
s_dir = paths.project_session_dir(workspace_path)
|
|
901
|
+
for cand in [s_dir / f"{resume_param}.jsonl", s_dir / resume_param, Path(resume_param)]:
|
|
902
|
+
if cand.exists():
|
|
903
|
+
try:
|
|
904
|
+
target_session = Session.load(cand)
|
|
905
|
+
break
|
|
906
|
+
except Exception:
|
|
907
|
+
continue
|
|
908
|
+
elif should_continue:
|
|
909
|
+
s_dir = paths.project_session_dir(workspace_path)
|
|
910
|
+
jsonl_files = sorted(s_dir.glob("*.jsonl"), key=lambda p: os.path.getmtime(p), reverse=True)
|
|
911
|
+
if jsonl_files:
|
|
912
|
+
try:
|
|
913
|
+
target_session = Session.load(jsonl_files[0])
|
|
914
|
+
except Exception:
|
|
915
|
+
target_session = None
|
|
916
|
+
|
|
917
|
+
if target_session is None:
|
|
918
|
+
if session_file.exists() and not bool(params.get("new_session", False)):
|
|
919
|
+
try:
|
|
920
|
+
target_session = Session.load(session_file)
|
|
921
|
+
except Exception:
|
|
922
|
+
target_session = Session(path=session_file, cwd=str(workspace_path))
|
|
923
|
+
else:
|
|
924
|
+
target_session = Session(path=session_file, cwd=str(workspace_path))
|
|
925
|
+
|
|
926
|
+
if bool(params.get("no_session", False)):
|
|
927
|
+
target_session.save = lambda: None # type: ignore[method-assign]
|
|
928
|
+
|
|
929
|
+
gate = PermissionGate(mode=mode) if mode else None
|
|
930
|
+
self.agent = CodingAgent(
|
|
931
|
+
workspace=workspace_path,
|
|
932
|
+
llm=llm,
|
|
933
|
+
session=target_session,
|
|
934
|
+
permission_gate=gate,
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
if initial_name := params.get("name"):
|
|
938
|
+
name_str = str(initial_name).strip()
|
|
939
|
+
if name_str:
|
|
940
|
+
self.agent.session.metadata["name"] = name_str
|
|
941
|
+
self.agent.session.metadata["title"] = name_str
|
|
942
|
+
info_entry = SessionInfoEntry(
|
|
943
|
+
name=name_str,
|
|
944
|
+
title=name_str,
|
|
945
|
+
cwd=str(workspace_path),
|
|
946
|
+
parent_id=self.agent.session.tree.current_id,
|
|
947
|
+
)
|
|
948
|
+
self.agent.session.store.append_entry(info_entry)
|
|
949
|
+
|
|
950
|
+
if initial_thinking := params.get("thinking"):
|
|
951
|
+
thinking_str = str(initial_thinking).strip().lower()
|
|
952
|
+
valid_levels = {"off", "minimal", "low", "medium", "high", "xhigh", "max"}
|
|
953
|
+
if thinking_str in valid_levels:
|
|
954
|
+
setattr(self.agent, "thinking_level", thinking_str)
|
|
955
|
+
t_entry = ThinkingLevelChangeEntry(
|
|
956
|
+
thinking_level=thinking_str,
|
|
957
|
+
parent_id=self.agent.session.tree.current_id,
|
|
958
|
+
)
|
|
959
|
+
self.agent.session.append_entry(t_entry)
|
|
960
|
+
|
|
961
|
+
self.macro_engine = MacroEngine(workspace=workspace_path, paths=paths)
|
|
962
|
+
|
|
963
|
+
messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
|
|
964
|
+
|
|
965
|
+
actual_model = getattr(getattr(self.agent.agent.llm, "config", None), "model", "default")
|
|
966
|
+
actual_provider = getattr(getattr(self.agent.agent.llm, "config", None), "provider", "default")
|
|
967
|
+
ctx_win = resolve_model_context_window(actual_model)
|
|
968
|
+
self.session_usage = self._compute_session_usage(target_session, actual_model)
|
|
969
|
+
|
|
970
|
+
return self.send_response(
|
|
971
|
+
req_id,
|
|
972
|
+
result={
|
|
973
|
+
"status": "ok",
|
|
974
|
+
"workspace": str(workspace_path),
|
|
975
|
+
"model": actual_model,
|
|
976
|
+
"provider": actual_provider,
|
|
977
|
+
"context_window": ctx_win,
|
|
978
|
+
"usage": self.session_usage,
|
|
979
|
+
"thinking_level": getattr(self.agent, "thinking_level", "off"),
|
|
980
|
+
"session_id": target_session.id,
|
|
981
|
+
"session_file": str(target_session.path) if target_session.path else "",
|
|
982
|
+
"session_name": target_session.metadata.get("name")
|
|
983
|
+
or target_session.metadata.get("title")
|
|
984
|
+
or target_session.id,
|
|
985
|
+
"messages": messages_repr,
|
|
986
|
+
},
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
async def _handle_prompt(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
990
|
+
if not self.agent:
|
|
991
|
+
return self.send_response(
|
|
992
|
+
req_id,
|
|
993
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
994
|
+
)
|
|
995
|
+
if getattr(self.agent.agent, "llm", None) is None:
|
|
996
|
+
return self.send_response(
|
|
997
|
+
req_id,
|
|
998
|
+
error={
|
|
999
|
+
"code": -32002,
|
|
1000
|
+
"message": "未检测到有效模型凭据。请在当前项目 .env 文件中配置 OPENAI_API_KEY 或 DEEPSEEK_API_KEY,或输入 /login 绑定 Key。",
|
|
1001
|
+
},
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
text = params.get("text", "")
|
|
1005
|
+
async for event in self.agent.run_stream(text):
|
|
1006
|
+
if isinstance(event, MessageEnd) and event.message and event.message.role == "assistant":
|
|
1007
|
+
meta = event.message.metadata or {}
|
|
1008
|
+
usage = meta.get("usage")
|
|
1009
|
+
if usage and isinstance(usage, dict):
|
|
1010
|
+
prompt_tok = usage.get("prompt_tokens") or usage.get("input") or 0
|
|
1011
|
+
comp_tok = usage.get("completion_tokens") or usage.get("output") or 0
|
|
1012
|
+
cache_read = (
|
|
1013
|
+
usage.get("cache_read_tokens") or usage.get("cache_read") or usage.get("cacheRead") or 0
|
|
1014
|
+
)
|
|
1015
|
+
cache_write = (
|
|
1016
|
+
usage.get("cache_write_tokens") or usage.get("cache_write") or usage.get("cacheWrite") or 0
|
|
1017
|
+
)
|
|
1018
|
+
total_tok = usage.get("total_tokens") or usage.get("total") or (prompt_tok + comp_tok)
|
|
1019
|
+
|
|
1020
|
+
self.session_usage["input"] += prompt_tok
|
|
1021
|
+
self.session_usage["output"] += comp_tok
|
|
1022
|
+
self.session_usage["cacheRead"] += cache_read
|
|
1023
|
+
self.session_usage["cacheWrite"] += cache_write
|
|
1024
|
+
self.session_usage["total"] += total_tok
|
|
1025
|
+
self.session_usage["contextTokens"] = prompt_tok + comp_tok + cache_read + cache_write
|
|
1026
|
+
|
|
1027
|
+
total_prompt = prompt_tok + cache_read + cache_write
|
|
1028
|
+
if total_prompt > 0 and cache_read > 0:
|
|
1029
|
+
hit_rate = (cache_read / total_prompt) * 100.0
|
|
1030
|
+
self.session_usage["latestCacheHitRate"] = hit_rate
|
|
1031
|
+
self.session_usage["cacheHitRate"] = hit_rate
|
|
1032
|
+
|
|
1033
|
+
model_id = getattr(self.agent.agent, "model", "") or ""
|
|
1034
|
+
model_lower = model_id.lower()
|
|
1035
|
+
if "gemini" in model_lower:
|
|
1036
|
+
self.session_usage["cost"] += (
|
|
1037
|
+
prompt_tok * 0.1 + comp_tok * 0.4 + cache_read * 0.025
|
|
1038
|
+
) / 1000000.0
|
|
1039
|
+
elif "claude" in model_lower:
|
|
1040
|
+
if "opus" in model_lower:
|
|
1041
|
+
self.session_usage["cost"] += (
|
|
1042
|
+
prompt_tok * 15.0 + comp_tok * 75.0 + cache_read * 1.5
|
|
1043
|
+
) / 1000000.0
|
|
1044
|
+
else:
|
|
1045
|
+
self.session_usage["cost"] += (
|
|
1046
|
+
prompt_tok * 3.0 + comp_tok * 15.0 + cache_read * 0.3
|
|
1047
|
+
) / 1000000.0
|
|
1048
|
+
elif "deepseek" in model_lower:
|
|
1049
|
+
self.session_usage["cost"] += (
|
|
1050
|
+
prompt_tok * 0.14 + comp_tok * 0.28 + cache_read * 0.014
|
|
1051
|
+
) / 1000000.0
|
|
1052
|
+
elif "gpt-4o" in model_lower:
|
|
1053
|
+
self.session_usage["cost"] += (
|
|
1054
|
+
prompt_tok * 2.5 + comp_tok * 10.0 + cache_read * 1.25
|
|
1055
|
+
) / 1000000.0
|
|
1056
|
+
|
|
1057
|
+
model_name = getattr(self.agent.agent, "model", "") if self.agent else ""
|
|
1058
|
+
ctx_win = resolve_model_context_window(model_name)
|
|
1059
|
+
context_tok = 0
|
|
1060
|
+
ctx_inst = getattr(self.agent, "_ctx", None) or getattr(getattr(self.agent, "agent", None), "_ctx", None)
|
|
1061
|
+
if ctx_inst is not None:
|
|
1062
|
+
context_tok = getattr(ctx_inst, "total_tokens", 0) or getattr(ctx_inst, "last_token_count", 0)
|
|
1063
|
+
if not context_tok:
|
|
1064
|
+
context_tok = self.session_usage["total"]
|
|
1065
|
+
|
|
1066
|
+
stats = {
|
|
1067
|
+
"usage": {
|
|
1068
|
+
"input": self.session_usage["input"],
|
|
1069
|
+
"output": self.session_usage["output"],
|
|
1070
|
+
"cacheRead": self.session_usage["cacheRead"],
|
|
1071
|
+
"cacheWrite": self.session_usage["cacheWrite"],
|
|
1072
|
+
"cacheHitRate": self.session_usage["latestCacheHitRate"],
|
|
1073
|
+
"total": self.session_usage["total"],
|
|
1074
|
+
"contextTokens": context_tok,
|
|
1075
|
+
"cost": self.session_usage["cost"],
|
|
1076
|
+
},
|
|
1077
|
+
"contextWindow": ctx_win,
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
serialized = serialize_event(event, stats=stats)
|
|
1081
|
+
self.send_notification("event", serialized)
|
|
1082
|
+
|
|
1083
|
+
return self.send_response(req_id, result={"status": "completed"})
|
|
1084
|
+
|
|
1085
|
+
def _handle_login(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1086
|
+
provider = params.get("provider", "").lower().strip()
|
|
1087
|
+
key = params.get("key", "").strip()
|
|
1088
|
+
|
|
1089
|
+
paths = self.paths or AgentPaths()
|
|
1090
|
+
paths.ensure_directories()
|
|
1091
|
+
self.paths = paths
|
|
1092
|
+
auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
|
|
1093
|
+
self.auth_mgr = auth_mgr
|
|
1094
|
+
|
|
1095
|
+
# 1. 特殊处理 antigravity:自动关联本地 pi-antigravity 的 auth.json,免 Web 登录
|
|
1096
|
+
if provider == "antigravity":
|
|
1097
|
+
home = Path(os.environ.get("USERPROFILE") or os.environ.get("HOME") or "~").expanduser()
|
|
1098
|
+
pi_auth_candidates = [
|
|
1099
|
+
paths.auth_path,
|
|
1100
|
+
home / ".my-pi-agent" / "auth.json",
|
|
1101
|
+
home / ".pi" / "agent" / "auth.json",
|
|
1102
|
+
home / ".pi" / "auth.json",
|
|
1103
|
+
]
|
|
1104
|
+
synced_cred: dict[str, Any] | None = None
|
|
1105
|
+
source_file: Path | None = None
|
|
1106
|
+
for p in pi_auth_candidates:
|
|
1107
|
+
if p.exists():
|
|
1108
|
+
try:
|
|
1109
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
1110
|
+
if isinstance(data, dict):
|
|
1111
|
+
entry = data.get("antigravity") or data.get("google-antigravity")
|
|
1112
|
+
if entry and isinstance(entry, dict):
|
|
1113
|
+
synced_cred = entry
|
|
1114
|
+
source_file = p
|
|
1115
|
+
break
|
|
1116
|
+
except Exception:
|
|
1117
|
+
continue
|
|
1118
|
+
|
|
1119
|
+
if synced_cred:
|
|
1120
|
+
target_auth = paths.auth_path
|
|
1121
|
+
target_auth.parent.mkdir(parents=True, exist_ok=True)
|
|
1122
|
+
current_data: dict[str, Any] = {}
|
|
1123
|
+
if target_auth.exists():
|
|
1124
|
+
try:
|
|
1125
|
+
current_data = json.loads(target_auth.read_text(encoding="utf-8"))
|
|
1126
|
+
except Exception:
|
|
1127
|
+
current_data = {}
|
|
1128
|
+
current_data["antigravity"] = synced_cred
|
|
1129
|
+
target_auth.write_text(json.dumps(current_data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1130
|
+
|
|
1131
|
+
access_tok = synced_cred.get("access") or synced_cred.get("access_token")
|
|
1132
|
+
if access_tok:
|
|
1133
|
+
os.environ["ANTIGRAVITY_ACCESS_TOKEN"] = str(access_tok)
|
|
1134
|
+
email_info = f" (Google 账号: {synced_cred.get('email')})" if synced_cred.get("email") else ""
|
|
1135
|
+
return self.send_response(
|
|
1136
|
+
req_id,
|
|
1137
|
+
result={
|
|
1138
|
+
"status": "ok",
|
|
1139
|
+
"message": f"✓ 已成功同步并绑定 pi-antigravity 认证凭据 ({source_file}{email_info}),无需网页登录!",
|
|
1140
|
+
},
|
|
1141
|
+
)
|
|
1142
|
+
elif key:
|
|
1143
|
+
os.environ["ANTIGRAVITY_ACCESS_TOKEN"] = key
|
|
1144
|
+
auth_mgr.set_api_key(provider="antigravity", key=key)
|
|
1145
|
+
return self.send_response(
|
|
1146
|
+
req_id,
|
|
1147
|
+
result={
|
|
1148
|
+
"status": "ok",
|
|
1149
|
+
"message": "✓ 已成功绑定 Antigravity 凭据至凭据中心!",
|
|
1150
|
+
},
|
|
1151
|
+
)
|
|
1152
|
+
else:
|
|
1153
|
+
return self.send_response(
|
|
1154
|
+
req_id,
|
|
1155
|
+
result={
|
|
1156
|
+
"status": "error",
|
|
1157
|
+
"message": "未在 ~/.my-pi-agent/auth.json 或 ~/.pi/agent/auth.json 中检测到 antigravity 凭据。请将包含 antigravity 字段的 auth.json 放置到上述路径,或直接在登录框中粘贴 Access Token。",
|
|
1158
|
+
},
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
# 2. 其它提供商(如 deepseek, openai, anthropic):配置 API Key
|
|
1162
|
+
if not provider or not key:
|
|
1163
|
+
return self.send_response(
|
|
1164
|
+
req_id,
|
|
1165
|
+
result={
|
|
1166
|
+
"status": "info",
|
|
1167
|
+
"message": "请使用: /login <provider> <key>,例如: /login deepseek sk-xxxx 或 /login openai sk-xxxx",
|
|
1168
|
+
},
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
key_name = f"{provider.upper()}_API_KEY"
|
|
1172
|
+
os.environ[key_name] = key
|
|
1173
|
+
auth_mgr.set_api_key(provider=provider, key=key)
|
|
1174
|
+
|
|
1175
|
+
return self.send_response(
|
|
1176
|
+
req_id,
|
|
1177
|
+
result={
|
|
1178
|
+
"status": "ok",
|
|
1179
|
+
"message": f"✓ 已成功绑定 {provider} API Key 至全局凭据中心 (~/.my-pi-agent/auth.json)!",
|
|
1180
|
+
},
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
def _handle_steer(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1184
|
+
if not self.agent:
|
|
1185
|
+
return self.send_response(
|
|
1186
|
+
req_id,
|
|
1187
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1188
|
+
)
|
|
1189
|
+
msg = params.get("message", "")
|
|
1190
|
+
self.agent.steer(msg)
|
|
1191
|
+
return self.send_response(req_id, result={"status": "ok"})
|
|
1192
|
+
|
|
1193
|
+
def _handle_followup(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1194
|
+
if not self.agent:
|
|
1195
|
+
return self.send_response(
|
|
1196
|
+
req_id,
|
|
1197
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1198
|
+
)
|
|
1199
|
+
msg = params.get("message", "")
|
|
1200
|
+
self.agent.follow_up(msg)
|
|
1201
|
+
return self.send_response(req_id, result={"status": "ok"})
|
|
1202
|
+
|
|
1203
|
+
def _handle_abort(self, req_id: Any) -> dict[str, Any]:
|
|
1204
|
+
if self.agent:
|
|
1205
|
+
self.agent.abort()
|
|
1206
|
+
return self.send_response(req_id, result={"status": "ok"})
|
|
1207
|
+
|
|
1208
|
+
def _handle_session_name(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1209
|
+
if not self.agent:
|
|
1210
|
+
return self.send_response(
|
|
1211
|
+
req_id,
|
|
1212
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1213
|
+
)
|
|
1214
|
+
name = params.get("name", "").strip()
|
|
1215
|
+
if not name:
|
|
1216
|
+
return self.send_response(
|
|
1217
|
+
req_id,
|
|
1218
|
+
error={"code": -32602, "message": "Missing 'name' parameter"},
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
entry = SessionInfoEntry(
|
|
1222
|
+
name=name,
|
|
1223
|
+
title=name,
|
|
1224
|
+
cwd=str(self.agent.workspace),
|
|
1225
|
+
parent_id=self.agent.session.tree.current_id,
|
|
1226
|
+
)
|
|
1227
|
+
self.agent.session.store.append_entry(entry)
|
|
1228
|
+
|
|
1229
|
+
return self.send_response(
|
|
1230
|
+
req_id,
|
|
1231
|
+
result={
|
|
1232
|
+
"status": "ok",
|
|
1233
|
+
"name": name,
|
|
1234
|
+
},
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
def _handle_session_list(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1238
|
+
paths = self.paths or AgentPaths()
|
|
1239
|
+
workspace_path = Path(self.agent.workspace if self.agent else params.get("workspace", ".")).resolve()
|
|
1240
|
+
all_projects = bool(params.get("all_projects", False))
|
|
1241
|
+
|
|
1242
|
+
target_dirs: list[Path] = []
|
|
1243
|
+
if all_projects:
|
|
1244
|
+
if paths.sessions_dir.exists():
|
|
1245
|
+
target_dirs = [d for d in paths.sessions_dir.iterdir() if d.is_dir()]
|
|
1246
|
+
else:
|
|
1247
|
+
proj_dir = paths.project_session_dir(workspace_path)
|
|
1248
|
+
if proj_dir.exists():
|
|
1249
|
+
target_dirs = [proj_dir]
|
|
1250
|
+
|
|
1251
|
+
sessions_meta: list[dict[str, Any]] = []
|
|
1252
|
+
for s_dir in target_dirs:
|
|
1253
|
+
for f in s_dir.glob("*.jsonl"):
|
|
1254
|
+
try:
|
|
1255
|
+
with open(f, encoding="utf-8") as fh:
|
|
1256
|
+
line1 = fh.readline()
|
|
1257
|
+
if not line1:
|
|
1258
|
+
continue
|
|
1259
|
+
header = json.loads(line1)
|
|
1260
|
+
sid = header.get("id") or f.stem
|
|
1261
|
+
cwd_val = header.get("cwd", "")
|
|
1262
|
+
created_val = header.get("createdAt") or header.get("created_at") or os.path.getctime(f)
|
|
1263
|
+
meta_obj = header.get("metadata") or {}
|
|
1264
|
+
s_name = (
|
|
1265
|
+
header.get("name") or header.get("title") or meta_obj.get("name") or meta_obj.get("title")
|
|
1266
|
+
)
|
|
1267
|
+
parent_session = (
|
|
1268
|
+
header.get("parentSession")
|
|
1269
|
+
or header.get("parent_session")
|
|
1270
|
+
or header.get("parentSessionId")
|
|
1271
|
+
or header.get("parent_session_id")
|
|
1272
|
+
or meta_obj.get("parent_session_path")
|
|
1273
|
+
or meta_obj.get("parent_session_id")
|
|
1274
|
+
or meta_obj.get("parentSession")
|
|
1275
|
+
)
|
|
1276
|
+
first_msg_text = None
|
|
1277
|
+
msg_count = 0
|
|
1278
|
+
for line in fh:
|
|
1279
|
+
line_str = line.strip()
|
|
1280
|
+
if not line_str:
|
|
1281
|
+
continue
|
|
1282
|
+
try:
|
|
1283
|
+
entry_data = json.loads(line_str)
|
|
1284
|
+
etype = entry_data.get("type")
|
|
1285
|
+
if etype == "message":
|
|
1286
|
+
msg_count += 1
|
|
1287
|
+
if first_msg_text is None and entry_data.get("message", {}).get("role") == "user":
|
|
1288
|
+
content = entry_data.get("message", {}).get("content", "")
|
|
1289
|
+
if isinstance(content, str) and content.strip():
|
|
1290
|
+
first_msg_text = content.strip().splitlines()[0][:60]
|
|
1291
|
+
elif etype in ("session_info", "sessionInfo"):
|
|
1292
|
+
latest_name = entry_data.get("name") or entry_data.get("title")
|
|
1293
|
+
if latest_name:
|
|
1294
|
+
s_name = latest_name
|
|
1295
|
+
except Exception:
|
|
1296
|
+
continue
|
|
1297
|
+
|
|
1298
|
+
modified_val = os.path.getmtime(f)
|
|
1299
|
+
sessions_meta.append(
|
|
1300
|
+
{
|
|
1301
|
+
"id": sid,
|
|
1302
|
+
"name": s_name or first_msg_text or sid,
|
|
1303
|
+
"first_message": first_msg_text,
|
|
1304
|
+
"path": str(f.resolve()),
|
|
1305
|
+
"cwd": cwd_val,
|
|
1306
|
+
"modified": modified_val,
|
|
1307
|
+
"created_at": created_val,
|
|
1308
|
+
"message_count": msg_count,
|
|
1309
|
+
"parent_session": parent_session,
|
|
1310
|
+
"parent_session_path": parent_session,
|
|
1311
|
+
}
|
|
1312
|
+
)
|
|
1313
|
+
except Exception:
|
|
1314
|
+
continue
|
|
1315
|
+
|
|
1316
|
+
sessions_meta.sort(key=lambda s: s.get("modified", 0), reverse=True)
|
|
1317
|
+
return self.send_response(
|
|
1318
|
+
req_id,
|
|
1319
|
+
result={
|
|
1320
|
+
"status": "ok",
|
|
1321
|
+
"sessions": sessions_meta,
|
|
1322
|
+
},
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
def _handle_session_resume(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1326
|
+
session_id = params.get("session_id") or params.get("id") or params.get("path") or params.get("session_file")
|
|
1327
|
+
if not session_id:
|
|
1328
|
+
return self.send_response(
|
|
1329
|
+
req_id,
|
|
1330
|
+
error={"code": -32602, "message": "Missing session_id parameter"},
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
paths = self.paths or AgentPaths()
|
|
1334
|
+
workspace_path = Path(self.agent.workspace if self.agent else params.get("workspace", ".")).resolve()
|
|
1335
|
+
session_dir = paths.project_session_dir(workspace_path)
|
|
1336
|
+
|
|
1337
|
+
target_file: Path | None = None
|
|
1338
|
+
cand = Path(session_id)
|
|
1339
|
+
if cand.is_file():
|
|
1340
|
+
cand_resolved = cand.resolve()
|
|
1341
|
+
if cand_resolved.is_relative_to(paths.sessions_dir) or cand_resolved.is_relative_to(workspace_path):
|
|
1342
|
+
target_file = cand_resolved
|
|
1343
|
+
elif "/" not in session_id and "\\" not in session_id and (session_dir / f"{session_id}.jsonl").is_file():
|
|
1344
|
+
target_file = session_dir / f"{session_id}.jsonl"
|
|
1345
|
+
elif "/" not in session_id and "\\" not in session_id and (session_dir / session_id).is_file():
|
|
1346
|
+
target_file = session_dir / session_id
|
|
1347
|
+
else:
|
|
1348
|
+
matches: list[Path] = []
|
|
1349
|
+
if session_dir.exists():
|
|
1350
|
+
for f in session_dir.glob("*.jsonl"):
|
|
1351
|
+
try:
|
|
1352
|
+
with open(f, encoding="utf-8") as fh:
|
|
1353
|
+
first_line = fh.readline()
|
|
1354
|
+
if first_line:
|
|
1355
|
+
header = json.loads(first_line)
|
|
1356
|
+
fid = header.get("id", "")
|
|
1357
|
+
if (
|
|
1358
|
+
fid == session_id
|
|
1359
|
+
or fid.startswith(session_id)
|
|
1360
|
+
or f.stem == session_id
|
|
1361
|
+
or f.stem.startswith(session_id)
|
|
1362
|
+
):
|
|
1363
|
+
matches.append(f)
|
|
1364
|
+
except Exception:
|
|
1365
|
+
continue
|
|
1366
|
+
|
|
1367
|
+
if not matches and paths.sessions_dir.exists():
|
|
1368
|
+
for s_dir in paths.sessions_dir.iterdir():
|
|
1369
|
+
if not s_dir.is_dir() or s_dir == session_dir:
|
|
1370
|
+
continue
|
|
1371
|
+
for f in s_dir.glob("*.jsonl"):
|
|
1372
|
+
try:
|
|
1373
|
+
with open(f, encoding="utf-8") as fh:
|
|
1374
|
+
first_line = fh.readline()
|
|
1375
|
+
if first_line:
|
|
1376
|
+
header = json.loads(first_line)
|
|
1377
|
+
fid = header.get("id", "")
|
|
1378
|
+
if (
|
|
1379
|
+
fid == session_id
|
|
1380
|
+
or fid.startswith(session_id)
|
|
1381
|
+
or f.stem == session_id
|
|
1382
|
+
or f.stem.startswith(session_id)
|
|
1383
|
+
):
|
|
1384
|
+
matches.append(f)
|
|
1385
|
+
except Exception:
|
|
1386
|
+
continue
|
|
1387
|
+
|
|
1388
|
+
if len(matches) == 1:
|
|
1389
|
+
target_file = matches[0]
|
|
1390
|
+
elif len(matches) > 1:
|
|
1391
|
+
return self.send_response(
|
|
1392
|
+
req_id,
|
|
1393
|
+
error={
|
|
1394
|
+
"code": -32003,
|
|
1395
|
+
"message": f"Ambiguous session_id '{session_id}': {[m.name for m in matches]}",
|
|
1396
|
+
},
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
if target_file is None or not target_file.is_file():
|
|
1400
|
+
return self.send_response(
|
|
1401
|
+
req_id,
|
|
1402
|
+
error={"code": -32004, "message": f"Session file not found for '{session_id}'"},
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
new_session = Session.load(target_file)
|
|
1406
|
+
mode = getattr(self.settings, "default_permission_mode", None)
|
|
1407
|
+
gate = self.agent.permission_gate if self.agent else (PermissionGate(mode=mode) if mode else None)
|
|
1408
|
+
llm = self.agent.agent.llm if self.agent else self.llm
|
|
1409
|
+
self.agent = CodingAgent(
|
|
1410
|
+
workspace=workspace_path,
|
|
1411
|
+
llm=llm,
|
|
1412
|
+
session=new_session,
|
|
1413
|
+
permission_gate=gate,
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
|
|
1417
|
+
|
|
1418
|
+
actual_model = getattr(self.agent.agent, "model", "default")
|
|
1419
|
+
ctx_win = resolve_model_context_window(actual_model)
|
|
1420
|
+
self.session_usage = self._compute_session_usage(new_session, actual_model)
|
|
1421
|
+
|
|
1422
|
+
return self.send_response(
|
|
1423
|
+
req_id,
|
|
1424
|
+
result={
|
|
1425
|
+
"status": "ok",
|
|
1426
|
+
"session_id": new_session.id,
|
|
1427
|
+
"session_name": new_session.metadata.get("name") or new_session.metadata.get("title") or new_session.id,
|
|
1428
|
+
"session_file": str(target_file),
|
|
1429
|
+
"cwd": new_session.cwd,
|
|
1430
|
+
"model": actual_model,
|
|
1431
|
+
"context_window": ctx_win,
|
|
1432
|
+
"usage": self.session_usage,
|
|
1433
|
+
"messages": messages_repr,
|
|
1434
|
+
},
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
def _handle_session_delete(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1438
|
+
"""删除指定历史会话文件(严格对标 Pi 规范,禁止删除当前正在使用的活跃会话)。"""
|
|
1439
|
+
session_id = params.get("session_id") or params.get("id") or params.get("path") or params.get("session_file")
|
|
1440
|
+
if not session_id:
|
|
1441
|
+
return self.send_response(
|
|
1442
|
+
req_id,
|
|
1443
|
+
error={"code": -32602, "message": "Missing session_id parameter"},
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
# 检查是否为当前正在运行的活跃会话
|
|
1447
|
+
current_id = self.agent.session.id if self.agent and self.agent.session else None
|
|
1448
|
+
current_path = (
|
|
1449
|
+
str(self.agent.session.path) if self.agent and self.agent.session and self.agent.session.path else ""
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
if session_id == current_id or session_id == current_path:
|
|
1453
|
+
return self.send_response(
|
|
1454
|
+
req_id,
|
|
1455
|
+
error={"code": -32005, "message": "Cannot delete the currently active session"},
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
paths = self.paths or AgentPaths()
|
|
1459
|
+
workspace_path = Path(self.agent.workspace if self.agent else params.get("workspace", ".")).resolve()
|
|
1460
|
+
session_dir = paths.project_session_dir(workspace_path)
|
|
1461
|
+
|
|
1462
|
+
target_file: Path | None = None
|
|
1463
|
+
cand = Path(session_id)
|
|
1464
|
+
if cand.is_file():
|
|
1465
|
+
cand_resolved = cand.resolve()
|
|
1466
|
+
if cand_resolved.is_relative_to(paths.sessions_dir) or cand_resolved.is_relative_to(workspace_path):
|
|
1467
|
+
target_file = cand_resolved
|
|
1468
|
+
elif "/" not in session_id and "\\" not in session_id and (session_dir / f"{session_id}.jsonl").is_file():
|
|
1469
|
+
target_file = session_dir / f"{session_id}.jsonl"
|
|
1470
|
+
elif "/" not in session_id and "\\" not in session_id and (session_dir / session_id).is_file():
|
|
1471
|
+
target_file = session_dir / session_id
|
|
1472
|
+
else:
|
|
1473
|
+
if session_dir.exists():
|
|
1474
|
+
for f in session_dir.glob("*.jsonl"):
|
|
1475
|
+
if f.stem == session_id or f.stem.startswith(session_id):
|
|
1476
|
+
target_file = f
|
|
1477
|
+
break
|
|
1478
|
+
if target_file is None and paths.sessions_dir.exists():
|
|
1479
|
+
for s_dir in paths.sessions_dir.iterdir():
|
|
1480
|
+
if s_dir.is_dir():
|
|
1481
|
+
for f in s_dir.glob("*.jsonl"):
|
|
1482
|
+
if f.stem == session_id or f.stem.startswith(session_id):
|
|
1483
|
+
target_file = f
|
|
1484
|
+
break
|
|
1485
|
+
if target_file is not None:
|
|
1486
|
+
break
|
|
1487
|
+
|
|
1488
|
+
if target_file is None or not target_file.is_file():
|
|
1489
|
+
return self.send_response(
|
|
1490
|
+
req_id,
|
|
1491
|
+
error={"code": -32004, "message": f"Session file not found for '{session_id}'"},
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
if current_path and target_file.resolve() == Path(current_path).resolve():
|
|
1495
|
+
return self.send_response(
|
|
1496
|
+
req_id,
|
|
1497
|
+
error={"code": -32005, "message": "Cannot delete the currently active session"},
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
try:
|
|
1501
|
+
target_file.unlink()
|
|
1502
|
+
return self.send_response(
|
|
1503
|
+
req_id,
|
|
1504
|
+
result={
|
|
1505
|
+
"status": "ok",
|
|
1506
|
+
"deleted": str(target_file),
|
|
1507
|
+
},
|
|
1508
|
+
)
|
|
1509
|
+
except Exception as exc:
|
|
1510
|
+
return self.send_response(
|
|
1511
|
+
req_id,
|
|
1512
|
+
error={"code": -32000, "message": f"Failed to delete session file: {exc}"},
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
def _handle_session_history(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1516
|
+
if not self.agent:
|
|
1517
|
+
return self.send_response(
|
|
1518
|
+
req_id,
|
|
1519
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1520
|
+
)
|
|
1521
|
+
messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
|
|
1522
|
+
return self.send_response(
|
|
1523
|
+
req_id,
|
|
1524
|
+
result={
|
|
1525
|
+
"status": "ok",
|
|
1526
|
+
"session_id": self.agent.session.id,
|
|
1527
|
+
"session_name": self.agent.session.metadata.get("name") or self.agent.session.id,
|
|
1528
|
+
"messages": messages_repr,
|
|
1529
|
+
},
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
def _handle_session_stats(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1533
|
+
"""对标 Pi 官方 getSessionStats(),汇总会话全局 Message/Token/Cost 统计。"""
|
|
1534
|
+
if not self.agent or not self.agent.session:
|
|
1535
|
+
return self.send_response(
|
|
1536
|
+
req_id,
|
|
1537
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
session = self.agent.session
|
|
1541
|
+
entries = list(session.tree.entries.values())
|
|
1542
|
+
session_file = str(session.path) if session.path else "In-memory"
|
|
1543
|
+
session_id = session.id
|
|
1544
|
+
session_name = session.metadata.get("name") or session.metadata.get("title")
|
|
1545
|
+
|
|
1546
|
+
user_messages = 0
|
|
1547
|
+
assistant_messages = 0
|
|
1548
|
+
tool_calls = 0
|
|
1549
|
+
tool_results = 0
|
|
1550
|
+
total_messages = 0
|
|
1551
|
+
|
|
1552
|
+
input_tokens = 0
|
|
1553
|
+
output_tokens = 0
|
|
1554
|
+
cache_read_tokens = 0
|
|
1555
|
+
cache_write_tokens = 0
|
|
1556
|
+
total_cost = 0.0
|
|
1557
|
+
|
|
1558
|
+
usage_by_key: dict[str, dict[str, Any]] = {}
|
|
1559
|
+
prev_prompt_tokens = 0
|
|
1560
|
+
prev_reported_cache = False
|
|
1561
|
+
missed_tokens_total = 0
|
|
1562
|
+
missed_cost_total = 0.0
|
|
1563
|
+
miss_count_total = 0
|
|
1564
|
+
|
|
1565
|
+
for msg in session.get_full_history_messages():
|
|
1566
|
+
role = msg.role
|
|
1567
|
+
total_messages += 1
|
|
1568
|
+
if role == "user":
|
|
1569
|
+
user_messages += 1
|
|
1570
|
+
elif role == "tool":
|
|
1571
|
+
tool_results += 1
|
|
1572
|
+
elif role == "assistant":
|
|
1573
|
+
assistant_messages += 1
|
|
1574
|
+
if msg.metadata and msg.metadata.get("tool_calls"):
|
|
1575
|
+
tool_calls += len(msg.metadata["tool_calls"])
|
|
1576
|
+
|
|
1577
|
+
usage = msg.metadata.get("usage") if msg.metadata else None
|
|
1578
|
+
if usage and isinstance(usage, dict):
|
|
1579
|
+
try:
|
|
1580
|
+
in_t = int(usage.get("prompt_tokens") or usage.get("input") or 0)
|
|
1581
|
+
out_t = int(usage.get("completion_tokens") or usage.get("output") or 0)
|
|
1582
|
+
cr_t = int(
|
|
1583
|
+
usage.get("cache_read_tokens") or usage.get("cache_read") or usage.get("cacheRead") or 0
|
|
1584
|
+
)
|
|
1585
|
+
cw_t = int(
|
|
1586
|
+
usage.get("cache_write_tokens") or usage.get("cache_write") or usage.get("cacheWrite") or 0
|
|
1587
|
+
)
|
|
1588
|
+
except (ValueError, TypeError):
|
|
1589
|
+
in_t, out_t, cr_t, cw_t = 0, 0, 0, 0
|
|
1590
|
+
|
|
1591
|
+
input_tokens += in_t
|
|
1592
|
+
output_tokens += out_t
|
|
1593
|
+
cache_read_tokens += cr_t
|
|
1594
|
+
cache_write_tokens += cw_t
|
|
1595
|
+
|
|
1596
|
+
model_str = getattr(msg, "model", None) or self.agent.agent.model or "default"
|
|
1597
|
+
provider_str = getattr(msg, "provider", None) or getattr(self.agent.agent.llm, "provider", "model")
|
|
1598
|
+
breakdown_key = model_str if "/" in model_str else f"{provider_str}/{model_str}"
|
|
1599
|
+
|
|
1600
|
+
step_cost = 0.0
|
|
1601
|
+
m_lower = breakdown_key.lower()
|
|
1602
|
+
if "gemini" in m_lower:
|
|
1603
|
+
step_cost = (in_t * 0.1 + out_t * 0.4 + cr_t * 0.025) / 1000000.0
|
|
1604
|
+
elif "claude" in m_lower:
|
|
1605
|
+
if "opus" in m_lower:
|
|
1606
|
+
step_cost = (in_t * 15.0 + out_t * 75.0 + cr_t * 1.5) / 1000000.0
|
|
1607
|
+
else:
|
|
1608
|
+
step_cost = (in_t * 3.0 + out_t * 15.0 + cr_t * 0.3) / 1000000.0
|
|
1609
|
+
elif "deepseek" in m_lower:
|
|
1610
|
+
step_cost = (in_t * 0.14 + out_t * 0.28 + cr_t * 0.014) / 1000000.0
|
|
1611
|
+
elif "gpt-4o" in m_lower:
|
|
1612
|
+
step_cost = (in_t * 2.5 + out_t * 10.0 + cr_t * 1.25) / 1000000.0
|
|
1613
|
+
else:
|
|
1614
|
+
step_cost = (in_t * 1.0 + out_t * 3.0 + cr_t * 0.5) / 1000000.0
|
|
1615
|
+
|
|
1616
|
+
total_cost += step_cost
|
|
1617
|
+
|
|
1618
|
+
if breakdown_key not in usage_by_key:
|
|
1619
|
+
usage_by_key[breakdown_key] = {"key": breakdown_key, "cost": 0.0, "tokens": 0}
|
|
1620
|
+
usage_by_key[breakdown_key]["cost"] += step_cost
|
|
1621
|
+
usage_by_key[breakdown_key]["tokens"] += in_t + out_t + cr_t + cw_t
|
|
1622
|
+
|
|
1623
|
+
prompt_t = in_t + cr_t + cw_t
|
|
1624
|
+
if prev_prompt_tokens > 0 and (cr_t > 0 or prev_reported_cache):
|
|
1625
|
+
missed = min(prev_prompt_tokens, prompt_t) - cr_t
|
|
1626
|
+
if missed > 1000:
|
|
1627
|
+
missed_tokens_total += missed
|
|
1628
|
+
miss_count_total += 1
|
|
1629
|
+
missed_cost_total += (missed * 0.1) / 1000000.0
|
|
1630
|
+
|
|
1631
|
+
prev_prompt_tokens = prompt_t
|
|
1632
|
+
prev_reported_cache = cr_t > 0 or cw_t > 0
|
|
1633
|
+
|
|
1634
|
+
for entry in entries:
|
|
1635
|
+
if getattr(entry, "type", "") in ("compaction", "branch_summary"):
|
|
1636
|
+
summary_usage = getattr(entry, "usage", None) or getattr(entry, "metadata", {}).get("usage")
|
|
1637
|
+
if summary_usage and isinstance(summary_usage, dict):
|
|
1638
|
+
try:
|
|
1639
|
+
in_t = int(summary_usage.get("prompt_tokens") or summary_usage.get("input") or 0)
|
|
1640
|
+
out_t = int(summary_usage.get("completion_tokens") or summary_usage.get("output") or 0)
|
|
1641
|
+
except (ValueError, TypeError):
|
|
1642
|
+
in_t, out_t = 0, 0
|
|
1643
|
+
c_cost = (in_t * 0.5 + out_t * 1.5) / 1000000.0
|
|
1644
|
+
key = "Tools/summaries"
|
|
1645
|
+
if key not in usage_by_key:
|
|
1646
|
+
usage_by_key[key] = {"key": key, "cost": 0.0, "tokens": 0}
|
|
1647
|
+
usage_by_key[key]["cost"] += c_cost
|
|
1648
|
+
usage_by_key[key]["tokens"] += in_t + out_t
|
|
1649
|
+
total_cost += c_cost
|
|
1650
|
+
|
|
1651
|
+
breakdown_list = sorted(usage_by_key.values(), key=lambda x: x["cost"], reverse=True)
|
|
1652
|
+
|
|
1653
|
+
stats = {
|
|
1654
|
+
"sessionFile": session_file,
|
|
1655
|
+
"sessionId": session_id,
|
|
1656
|
+
"sessionName": session_name,
|
|
1657
|
+
"totalMessages": total_messages,
|
|
1658
|
+
"userMessages": user_messages,
|
|
1659
|
+
"assistantMessages": assistant_messages,
|
|
1660
|
+
"toolCalls": tool_calls,
|
|
1661
|
+
"toolResults": tool_results,
|
|
1662
|
+
"tokens": {
|
|
1663
|
+
"input": input_tokens,
|
|
1664
|
+
"output": output_tokens,
|
|
1665
|
+
"cacheRead": cache_read_tokens,
|
|
1666
|
+
"cacheWrite": cache_write_tokens,
|
|
1667
|
+
"total": input_tokens + output_tokens + cache_read_tokens + cache_write_tokens,
|
|
1668
|
+
},
|
|
1669
|
+
"cost": total_cost,
|
|
1670
|
+
"usageBreakdown": breakdown_list,
|
|
1671
|
+
"cacheWaste": {
|
|
1672
|
+
"missedTokens": missed_tokens_total,
|
|
1673
|
+
"missedCost": missed_cost_total,
|
|
1674
|
+
"missCount": miss_count_total,
|
|
1675
|
+
},
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
return self.send_response(req_id, result={"status": "ok", "stats": stats})
|
|
1679
|
+
|
|
1680
|
+
async def _handle_session_compact(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1681
|
+
if not self.agent:
|
|
1682
|
+
return self.send_response(
|
|
1683
|
+
req_id,
|
|
1684
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1685
|
+
)
|
|
1686
|
+
self.agent.abort()
|
|
1687
|
+
instructions = params.get("instructions")
|
|
1688
|
+
await self.agent.compact(instructions=instructions)
|
|
1689
|
+
|
|
1690
|
+
info = self.agent.agent.context_manager.pending_compaction
|
|
1691
|
+
tokens_before = info.tokens_before if info else 0
|
|
1692
|
+
tokens_after = info.tokens_after if info else 0
|
|
1693
|
+
summary = info.summary if info else ""
|
|
1694
|
+
self.session_usage["contextTokens"] = tokens_after
|
|
1695
|
+
|
|
1696
|
+
return self.send_response(
|
|
1697
|
+
req_id,
|
|
1698
|
+
result={
|
|
1699
|
+
"status": "ok",
|
|
1700
|
+
"tokens_before": tokens_before,
|
|
1701
|
+
"tokens_after": tokens_after,
|
|
1702
|
+
"summary": summary,
|
|
1703
|
+
},
|
|
1704
|
+
)
|
|
1705
|
+
|
|
1706
|
+
def _handle_session_new(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1707
|
+
if not self.agent:
|
|
1708
|
+
return self.send_response(
|
|
1709
|
+
req_id,
|
|
1710
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1711
|
+
)
|
|
1712
|
+
|
|
1713
|
+
self.agent.abort()
|
|
1714
|
+
workspace_path = Path(self.agent.workspace).resolve()
|
|
1715
|
+
paths = self.paths or AgentPaths()
|
|
1716
|
+
session_dir = paths.project_session_dir(workspace_path)
|
|
1717
|
+
session_id = uuid7_str()
|
|
1718
|
+
session_file = session_dir / f"{session_id}.jsonl"
|
|
1719
|
+
|
|
1720
|
+
new_session = Session(path=session_file, cwd=str(workspace_path))
|
|
1721
|
+
new_session.id = session_id
|
|
1722
|
+
mode = getattr(self.settings, "default_permission_mode", None)
|
|
1723
|
+
gate = self.agent.permission_gate or (PermissionGate(mode=mode) if mode else None)
|
|
1724
|
+
|
|
1725
|
+
self.agent = CodingAgent(
|
|
1726
|
+
workspace=workspace_path,
|
|
1727
|
+
llm=self.agent.agent.llm,
|
|
1728
|
+
session=new_session,
|
|
1729
|
+
permission_gate=gate,
|
|
1730
|
+
)
|
|
1731
|
+
|
|
1732
|
+
return self.send_response(
|
|
1733
|
+
req_id,
|
|
1734
|
+
result={
|
|
1735
|
+
"status": "ok",
|
|
1736
|
+
"session_id": session_id,
|
|
1737
|
+
"session_file": str(session_file),
|
|
1738
|
+
"messages": [],
|
|
1739
|
+
},
|
|
1740
|
+
)
|
|
1741
|
+
|
|
1742
|
+
def _handle_session_tree(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1743
|
+
if not self.agent:
|
|
1744
|
+
return self.send_response(
|
|
1745
|
+
req_id,
|
|
1746
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1747
|
+
)
|
|
1748
|
+
|
|
1749
|
+
session = self.agent.session
|
|
1750
|
+
entries = list(session.tree.entries.values())
|
|
1751
|
+
active_leaf_id = session.tree.current_id
|
|
1752
|
+
root_id = session.tree.root_id
|
|
1753
|
+
|
|
1754
|
+
active_path_ids: set[str] = set()
|
|
1755
|
+
if active_leaf_id and active_leaf_id in session.tree.entries:
|
|
1756
|
+
active_path_ids = {e.id for e in session.tree.get_current_path()}
|
|
1757
|
+
|
|
1758
|
+
parent_ids = {e.parent_id for e in entries if e.parent_id is not None}
|
|
1759
|
+
|
|
1760
|
+
nodes: list[dict[str, Any]] = []
|
|
1761
|
+
for entry in entries:
|
|
1762
|
+
eid = entry.id
|
|
1763
|
+
pid = entry.parent_id
|
|
1764
|
+
etype = getattr(entry, "type", "message")
|
|
1765
|
+
role = getattr(entry, "role", etype)
|
|
1766
|
+
|
|
1767
|
+
preview = ""
|
|
1768
|
+
if isinstance(entry, MessageEntry):
|
|
1769
|
+
msg = entry.message
|
|
1770
|
+
content = msg.content or ""
|
|
1771
|
+
if msg.metadata and msg.metadata.get("tool_calls"):
|
|
1772
|
+
tc_names = [
|
|
1773
|
+
tc.get("function", {}).get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
|
|
1774
|
+
for tc in msg.metadata.get("tool_calls", [])
|
|
1775
|
+
]
|
|
1776
|
+
prefix = f"[Tool Call: {', '.join(filter(None, tc_names))}]"
|
|
1777
|
+
preview = f"{prefix} {content}".strip() if content else prefix
|
|
1778
|
+
else:
|
|
1779
|
+
preview = content
|
|
1780
|
+
elif isinstance(entry, CompactionEntry):
|
|
1781
|
+
preview = entry.summary
|
|
1782
|
+
elif isinstance(entry, BranchSummaryEntry):
|
|
1783
|
+
preview = entry.summary
|
|
1784
|
+
elif isinstance(entry, SessionInfoEntry):
|
|
1785
|
+
preview = entry.name or entry.title or ""
|
|
1786
|
+
elif isinstance(entry, ModelChangeEntry):
|
|
1787
|
+
preview = f"Model: {entry.model}"
|
|
1788
|
+
elif isinstance(entry, ThinkingLevelChangeEntry):
|
|
1789
|
+
preview = f"Thinking: {entry.thinking_level}"
|
|
1790
|
+
elif isinstance(entry, LabelEntry):
|
|
1791
|
+
preview = f"Label: {entry.label}"
|
|
1792
|
+
elif isinstance(entry, LeafEntry):
|
|
1793
|
+
preview = f"Leaf: {entry.leaf_id}"
|
|
1794
|
+
else:
|
|
1795
|
+
preview = str(getattr(entry, "content", "") or getattr(entry, "summary", "") or "")
|
|
1796
|
+
|
|
1797
|
+
if len(preview) > 200:
|
|
1798
|
+
preview = preview[:200] + "..."
|
|
1799
|
+
|
|
1800
|
+
nodes.append(
|
|
1801
|
+
{
|
|
1802
|
+
"id": eid,
|
|
1803
|
+
"parent_id": pid,
|
|
1804
|
+
"role": role,
|
|
1805
|
+
"type": etype,
|
|
1806
|
+
"preview": preview,
|
|
1807
|
+
"is_leaf": eid not in parent_ids,
|
|
1808
|
+
"is_active": eid in active_path_ids,
|
|
1809
|
+
"timestamp": getattr(entry, "timestamp", 0.0),
|
|
1810
|
+
}
|
|
1811
|
+
)
|
|
1812
|
+
|
|
1813
|
+
return self.send_response(
|
|
1814
|
+
req_id,
|
|
1815
|
+
result={
|
|
1816
|
+
"status": "ok",
|
|
1817
|
+
"nodes": nodes,
|
|
1818
|
+
"tree": nodes,
|
|
1819
|
+
"active_leaf_id": active_leaf_id,
|
|
1820
|
+
"root_id": root_id,
|
|
1821
|
+
},
|
|
1822
|
+
)
|
|
1823
|
+
|
|
1824
|
+
async def _handle_session_branch(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1825
|
+
if not self.agent:
|
|
1826
|
+
return self.send_response(
|
|
1827
|
+
req_id,
|
|
1828
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1829
|
+
)
|
|
1830
|
+
|
|
1831
|
+
target_id = params.get("target_id") or params.get("node_id") or params.get("entry_id") or params.get("id")
|
|
1832
|
+
if not target_id:
|
|
1833
|
+
return self.send_response(
|
|
1834
|
+
req_id,
|
|
1835
|
+
error={"code": -32602, "message": "Missing 'target_id' parameter"},
|
|
1836
|
+
)
|
|
1837
|
+
|
|
1838
|
+
session = self.agent.session
|
|
1839
|
+
if target_id not in session.tree.entries:
|
|
1840
|
+
return self.send_response(
|
|
1841
|
+
req_id,
|
|
1842
|
+
error={"code": -32004, "message": f"Entry '{target_id}' not found in session"},
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
target_entry = session.tree.entries[target_id]
|
|
1846
|
+
is_user = getattr(target_entry, "role", None) == "user" or (
|
|
1847
|
+
isinstance(target_entry, MessageEntry) and target_entry.message.role == "user"
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
self.agent.abort()
|
|
1851
|
+
old_leaf_id = session.tree.current_id
|
|
1852
|
+
summarize = bool(params.get("summarize", False))
|
|
1853
|
+
branch_summary_text = ""
|
|
1854
|
+
|
|
1855
|
+
if is_user:
|
|
1856
|
+
new_leaf_id = target_entry.parent_id
|
|
1857
|
+
editor_text = (
|
|
1858
|
+
getattr(target_entry, "content", "")
|
|
1859
|
+
or (target_entry.message.content if isinstance(target_entry, MessageEntry) else "")
|
|
1860
|
+
or ""
|
|
1861
|
+
)
|
|
1862
|
+
else:
|
|
1863
|
+
new_leaf_id = target_id
|
|
1864
|
+
editor_text = ""
|
|
1865
|
+
|
|
1866
|
+
if new_leaf_id is not None and session.compaction_floor is not None:
|
|
1867
|
+
if not session._after_floor(new_leaf_id):
|
|
1868
|
+
return self.send_response(
|
|
1869
|
+
req_id,
|
|
1870
|
+
error={
|
|
1871
|
+
"code": -32005,
|
|
1872
|
+
"message": f"Cannot branch past compaction floor {session.compaction_floor}: entry {new_leaf_id} is prior to compacted history",
|
|
1873
|
+
},
|
|
1874
|
+
)
|
|
1875
|
+
elif new_leaf_id is None and session.compaction_floor is not None:
|
|
1876
|
+
return self.send_response(
|
|
1877
|
+
req_id,
|
|
1878
|
+
error={
|
|
1879
|
+
"code": -32005,
|
|
1880
|
+
"message": f"Cannot branch past compaction floor {session.compaction_floor}: root is prior to compacted history",
|
|
1881
|
+
},
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
if summarize and old_leaf_id and old_leaf_id != new_leaf_id:
|
|
1885
|
+
old_path = session.tree.get_path_to_entry(old_leaf_id)
|
|
1886
|
+
new_path_ids = (
|
|
1887
|
+
{e.id for e in session.tree.get_path_to_entry(new_leaf_id)}
|
|
1888
|
+
if new_leaf_id and new_leaf_id in session.tree.entries
|
|
1889
|
+
else set()
|
|
1890
|
+
)
|
|
1891
|
+
abandoned_entries = [e for e in old_path if e.id not in new_path_ids]
|
|
1892
|
+
|
|
1893
|
+
if abandoned_entries:
|
|
1894
|
+
lca = lowest_common_ancestor(session.tree.entries, old_leaf_id, new_leaf_id) if new_leaf_id else None
|
|
1895
|
+
summary_prompt = (
|
|
1896
|
+
"Please concisely summarize the key decisions, code changes, and exploration from this abandoned conversation branch in 1-2 sentences:\n"
|
|
1897
|
+
+ "\n".join(
|
|
1898
|
+
f"{getattr(e, 'role', 'entry')}: {getattr(e, 'content', '')}"
|
|
1899
|
+
for e in abandoned_entries
|
|
1900
|
+
if hasattr(e, "content") or hasattr(e, "message")
|
|
1901
|
+
)
|
|
1902
|
+
)
|
|
1903
|
+
try:
|
|
1904
|
+
llm = self.agent.agent.llm
|
|
1905
|
+
resp = await llm.achat([Message(role="user", content=summary_prompt)])
|
|
1906
|
+
branch_summary_text = getattr(resp, "content", "") or "Branch summary"
|
|
1907
|
+
except Exception:
|
|
1908
|
+
branch_summary_text = "Branch exploration summary"
|
|
1909
|
+
|
|
1910
|
+
summary_entry = BranchSummaryEntry(
|
|
1911
|
+
parent_id=new_leaf_id,
|
|
1912
|
+
summary=branch_summary_text,
|
|
1913
|
+
details={
|
|
1914
|
+
"abandoned_from": old_leaf_id,
|
|
1915
|
+
"abandoned_count": len(abandoned_entries),
|
|
1916
|
+
"lca": lca,
|
|
1917
|
+
},
|
|
1918
|
+
)
|
|
1919
|
+
session.tree.entries[summary_entry.id] = summary_entry
|
|
1920
|
+
session.tree.current_id = summary_entry.id
|
|
1921
|
+
session.save()
|
|
1922
|
+
new_leaf_id = summary_entry.id
|
|
1923
|
+
|
|
1924
|
+
if not (summarize and branch_summary_text):
|
|
1925
|
+
if new_leaf_id is None:
|
|
1926
|
+
session.tree.current_id = None
|
|
1927
|
+
session.save()
|
|
1928
|
+
else:
|
|
1929
|
+
session.tree.current_id = new_leaf_id
|
|
1930
|
+
session.save()
|
|
1931
|
+
|
|
1932
|
+
system = [m for m in self.agent.agent.messages if m.role == "system"]
|
|
1933
|
+
restored = system + session.get_full_history_messages()
|
|
1934
|
+
self.agent.agent.messages = list(repair_tool_history(restored).messages)
|
|
1935
|
+
|
|
1936
|
+
messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
|
|
1937
|
+
|
|
1938
|
+
res_payload: dict[str, Any] = {
|
|
1939
|
+
"status": "ok",
|
|
1940
|
+
"leaf_id": new_leaf_id,
|
|
1941
|
+
"editor_text": editor_text,
|
|
1942
|
+
"session_id": session.id,
|
|
1943
|
+
"messages": messages_repr,
|
|
1944
|
+
}
|
|
1945
|
+
if branch_summary_text:
|
|
1946
|
+
res_payload["branch_summary"] = branch_summary_text
|
|
1947
|
+
|
|
1948
|
+
return self.send_response(req_id, result=res_payload)
|
|
1949
|
+
|
|
1950
|
+
def _handle_session_fork(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
1951
|
+
if not self.agent:
|
|
1952
|
+
return self.send_response(
|
|
1953
|
+
req_id,
|
|
1954
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
1955
|
+
)
|
|
1956
|
+
|
|
1957
|
+
entry_id = params.get("entry_id") or params.get("id") or params.get("target_id")
|
|
1958
|
+
if not entry_id:
|
|
1959
|
+
return self.send_response(
|
|
1960
|
+
req_id,
|
|
1961
|
+
error={"code": -32602, "message": "Missing 'entry_id' parameter"},
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
session = self.agent.session
|
|
1965
|
+
if entry_id not in session.tree.entries:
|
|
1966
|
+
return self.send_response(
|
|
1967
|
+
req_id,
|
|
1968
|
+
error={"code": -32004, "message": f"Entry '{entry_id}' not found in session"},
|
|
1969
|
+
)
|
|
1970
|
+
|
|
1971
|
+
target_entry = session.tree.entries[entry_id]
|
|
1972
|
+
prompt_text = (
|
|
1973
|
+
getattr(target_entry, "content", "")
|
|
1974
|
+
or (target_entry.message.content if isinstance(target_entry, MessageEntry) else "")
|
|
1975
|
+
or ""
|
|
1976
|
+
)
|
|
1977
|
+
|
|
1978
|
+
cutoff_id = target_entry.parent_id
|
|
1979
|
+
path_entries = (
|
|
1980
|
+
session.tree.get_path_to_entry(cutoff_id) if cutoff_id and cutoff_id in session.tree.entries else []
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
self.agent.abort()
|
|
1984
|
+
workspace_path = Path(self.agent.workspace).resolve()
|
|
1985
|
+
paths = self.paths or AgentPaths()
|
|
1986
|
+
session_dir = paths.project_session_dir(workspace_path)
|
|
1987
|
+
new_session_id = uuid7_str()
|
|
1988
|
+
new_session_file = session_dir / f"{new_session_id}.jsonl"
|
|
1989
|
+
|
|
1990
|
+
new_session = Session(path=new_session_file, cwd=str(workspace_path))
|
|
1991
|
+
new_session.id = new_session_id
|
|
1992
|
+
new_session.metadata["parent_session_id"] = session.id
|
|
1993
|
+
new_session.metadata["parent_session_path"] = str(session.path) if session.path else ""
|
|
1994
|
+
new_session.metadata["parentSession"] = str(session.path) if session.path else session.id
|
|
1995
|
+
new_session.metadata["forked_from_entry_id"] = entry_id
|
|
1996
|
+
if prompt_text:
|
|
1997
|
+
fork_title = prompt_text.strip().splitlines()[0][:60]
|
|
1998
|
+
new_session.metadata["name"] = fork_title
|
|
1999
|
+
new_session.metadata["title"] = fork_title
|
|
2000
|
+
|
|
2001
|
+
for entry in path_entries:
|
|
2002
|
+
if isinstance(entry, MessageEntry):
|
|
2003
|
+
new_session.add_message(entry.role, entry.content, **(entry.metadata or {}))
|
|
2004
|
+
elif isinstance(entry, CompactionEntry):
|
|
2005
|
+
new_compaction = CompactionEntry(
|
|
2006
|
+
parent_id=new_session.tree.current_id,
|
|
2007
|
+
summary=entry.summary,
|
|
2008
|
+
replaces_entry_ids=list(entry.replaces_entry_ids),
|
|
2009
|
+
metadata=dict(entry.metadata),
|
|
2010
|
+
)
|
|
2011
|
+
new_session.append_entry(new_compaction)
|
|
2012
|
+
elif isinstance(entry, BranchSummaryEntry):
|
|
2013
|
+
new_bs = BranchSummaryEntry(
|
|
2014
|
+
parent_id=new_session.tree.current_id,
|
|
2015
|
+
summary=entry.summary,
|
|
2016
|
+
details=dict(entry.details),
|
|
2017
|
+
)
|
|
2018
|
+
new_session.append_entry(new_bs)
|
|
2019
|
+
else:
|
|
2020
|
+
copied = entry.model_copy(update={"parent_id": new_session.tree.current_id})
|
|
2021
|
+
new_session.append_entry(copied)
|
|
2022
|
+
|
|
2023
|
+
new_session.save()
|
|
2024
|
+
|
|
2025
|
+
mode = getattr(self.settings, "default_permission_mode", None)
|
|
2026
|
+
gate = self.agent.permission_gate or (PermissionGate(mode=mode) if mode else None)
|
|
2027
|
+
self.agent = CodingAgent(
|
|
2028
|
+
workspace=workspace_path,
|
|
2029
|
+
llm=self.agent.agent.llm,
|
|
2030
|
+
session=new_session,
|
|
2031
|
+
permission_gate=gate,
|
|
2032
|
+
)
|
|
2033
|
+
|
|
2034
|
+
messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
|
|
2035
|
+
|
|
2036
|
+
return self.send_response(
|
|
2037
|
+
req_id,
|
|
2038
|
+
result={
|
|
2039
|
+
"status": "ok",
|
|
2040
|
+
"new_session_id": new_session_id,
|
|
2041
|
+
"session_file": str(new_session_file),
|
|
2042
|
+
"prompt_text": prompt_text,
|
|
2043
|
+
"messages": messages_repr,
|
|
2044
|
+
},
|
|
2045
|
+
)
|
|
2046
|
+
|
|
2047
|
+
def _handle_session_clone(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2048
|
+
if not self.agent:
|
|
2049
|
+
return self.send_response(
|
|
2050
|
+
req_id,
|
|
2051
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
2052
|
+
)
|
|
2053
|
+
|
|
2054
|
+
session = self.agent.session
|
|
2055
|
+
active_leaf_id = session.tree.current_id
|
|
2056
|
+
path_entries = session.tree.get_current_path() if active_leaf_id else []
|
|
2057
|
+
|
|
2058
|
+
self.agent.abort()
|
|
2059
|
+
workspace_path = Path(self.agent.workspace).resolve()
|
|
2060
|
+
paths = self.paths or AgentPaths()
|
|
2061
|
+
session_dir = paths.project_session_dir(workspace_path)
|
|
2062
|
+
new_session_id = uuid7_str()
|
|
2063
|
+
new_session_file = session_dir / f"{new_session_id}.jsonl"
|
|
2064
|
+
|
|
2065
|
+
new_session = Session(path=new_session_file, cwd=str(workspace_path))
|
|
2066
|
+
new_session.id = new_session_id
|
|
2067
|
+
new_session.metadata["parent_session_id"] = session.id
|
|
2068
|
+
new_session.metadata["parent_session_path"] = str(session.path) if session.path else ""
|
|
2069
|
+
new_session.metadata["parentSession"] = str(session.path) if session.path else session.id
|
|
2070
|
+
cur_name = session.metadata.get("name") or session.metadata.get("title")
|
|
2071
|
+
clone_title = f"{cur_name} (clone)" if cur_name else f"Clone of {session.id[:8]}"
|
|
2072
|
+
new_session.metadata["name"] = clone_title
|
|
2073
|
+
new_session.metadata["title"] = clone_title
|
|
2074
|
+
if active_leaf_id:
|
|
2075
|
+
new_session.metadata["cloned_from_leaf_id"] = active_leaf_id
|
|
2076
|
+
|
|
2077
|
+
for entry in path_entries:
|
|
2078
|
+
if isinstance(entry, MessageEntry):
|
|
2079
|
+
new_session.add_message(entry.role, entry.content, **(entry.metadata or {}))
|
|
2080
|
+
elif isinstance(entry, CompactionEntry):
|
|
2081
|
+
new_compaction = CompactionEntry(
|
|
2082
|
+
parent_id=new_session.tree.current_id,
|
|
2083
|
+
summary=entry.summary,
|
|
2084
|
+
replaces_entry_ids=list(entry.replaces_entry_ids),
|
|
2085
|
+
metadata=dict(entry.metadata),
|
|
2086
|
+
)
|
|
2087
|
+
new_session.append_entry(new_compaction)
|
|
2088
|
+
elif isinstance(entry, BranchSummaryEntry):
|
|
2089
|
+
new_bs = BranchSummaryEntry(
|
|
2090
|
+
parent_id=new_session.tree.current_id,
|
|
2091
|
+
summary=entry.summary,
|
|
2092
|
+
details=dict(entry.details),
|
|
2093
|
+
)
|
|
2094
|
+
new_session.append_entry(new_bs)
|
|
2095
|
+
else:
|
|
2096
|
+
copied = entry.model_copy(update={"parent_id": new_session.tree.current_id})
|
|
2097
|
+
new_session.append_entry(copied)
|
|
2098
|
+
|
|
2099
|
+
new_session.save()
|
|
2100
|
+
|
|
2101
|
+
mode = getattr(self.settings, "default_permission_mode", None)
|
|
2102
|
+
gate = self.agent.permission_gate or (PermissionGate(mode=mode) if mode else None)
|
|
2103
|
+
self.agent = CodingAgent(
|
|
2104
|
+
workspace=workspace_path,
|
|
2105
|
+
llm=self.agent.agent.llm,
|
|
2106
|
+
session=new_session,
|
|
2107
|
+
permission_gate=gate,
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
messages_repr = [serialize_message(m) for m in self.agent.agent.messages if m.role != "system"]
|
|
2111
|
+
|
|
2112
|
+
return self.send_response(
|
|
2113
|
+
req_id,
|
|
2114
|
+
result={
|
|
2115
|
+
"status": "ok",
|
|
2116
|
+
"new_session_id": new_session_id,
|
|
2117
|
+
"session_file": str(new_session_file),
|
|
2118
|
+
"messages": messages_repr,
|
|
2119
|
+
},
|
|
2120
|
+
)
|
|
2121
|
+
|
|
2122
|
+
async def _handle_shell_exec(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2123
|
+
if not self.agent:
|
|
2124
|
+
return self.send_response(
|
|
2125
|
+
req_id,
|
|
2126
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
2127
|
+
)
|
|
2128
|
+
command = params.get("command", "").strip()
|
|
2129
|
+
if not command:
|
|
2130
|
+
return self.send_response(
|
|
2131
|
+
req_id,
|
|
2132
|
+
error={"code": -32602, "message": "Missing 'command' parameter"},
|
|
2133
|
+
)
|
|
2134
|
+
exclude_from_context = bool(params.get("exclude_from_context", False))
|
|
2135
|
+
try:
|
|
2136
|
+
timeout = float(params.get("timeout", 60.0))
|
|
2137
|
+
except (ValueError, TypeError):
|
|
2138
|
+
timeout = 60.0
|
|
2139
|
+
|
|
2140
|
+
cwd = Path(self.agent.workspace)
|
|
2141
|
+
res = await asyncio.to_thread(
|
|
2142
|
+
self.macro_engine.execute_shell,
|
|
2143
|
+
command=command,
|
|
2144
|
+
cwd=cwd,
|
|
2145
|
+
exclude_from_context=exclude_from_context,
|
|
2146
|
+
timeout=timeout,
|
|
2147
|
+
)
|
|
2148
|
+
|
|
2149
|
+
if not exclude_from_context:
|
|
2150
|
+
output_str = res.get("output", "")
|
|
2151
|
+
if output_str:
|
|
2152
|
+
formatted = f"Ran `{command}`\n```text\n{output_str}\n```"
|
|
2153
|
+
else:
|
|
2154
|
+
formatted = f"Ran `{command}`\n```text\n```"
|
|
2155
|
+
|
|
2156
|
+
self.agent.session.add_message(
|
|
2157
|
+
role="user",
|
|
2158
|
+
content=formatted,
|
|
2159
|
+
metadata={
|
|
2160
|
+
"type": "bashExecution",
|
|
2161
|
+
"customType": "bashExecution",
|
|
2162
|
+
"command": command,
|
|
2163
|
+
"exit_code": res.get("exit_code"),
|
|
2164
|
+
"exclude_from_context": False,
|
|
2165
|
+
},
|
|
2166
|
+
)
|
|
2167
|
+
|
|
2168
|
+
return self.send_response(
|
|
2169
|
+
req_id,
|
|
2170
|
+
result={
|
|
2171
|
+
"status": "ok",
|
|
2172
|
+
"output": res.get("output", ""),
|
|
2173
|
+
"exit_code": res.get("exit_code", 0),
|
|
2174
|
+
},
|
|
2175
|
+
)
|
|
2176
|
+
|
|
2177
|
+
def _handle_macro_expand(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2178
|
+
if self.agent:
|
|
2179
|
+
self.macro_engine.workspace = Path(self.agent.workspace)
|
|
2180
|
+
text = str(params.get("text", ""))
|
|
2181
|
+
skills_dir_param = params.get("skills_dir")
|
|
2182
|
+
prompts_dir_param = params.get("prompts_dir")
|
|
2183
|
+
|
|
2184
|
+
skills_dir = Path(skills_dir_param) if skills_dir_param else None
|
|
2185
|
+
prompts_dir = Path(prompts_dir_param) if prompts_dir_param else None
|
|
2186
|
+
|
|
2187
|
+
expanded_text, is_expanded = self.macro_engine.expand_macro(
|
|
2188
|
+
text,
|
|
2189
|
+
skills_dir=skills_dir,
|
|
2190
|
+
prompts_dir=prompts_dir,
|
|
2191
|
+
)
|
|
2192
|
+
|
|
2193
|
+
return self.send_response(
|
|
2194
|
+
req_id,
|
|
2195
|
+
result={
|
|
2196
|
+
"status": "ok",
|
|
2197
|
+
"text": expanded_text,
|
|
2198
|
+
"expanded": is_expanded,
|
|
2199
|
+
"expanded_text": expanded_text,
|
|
2200
|
+
},
|
|
2201
|
+
)
|
|
2202
|
+
|
|
2203
|
+
def _handle_model_switch(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2204
|
+
if not self.agent:
|
|
2205
|
+
return self.send_response(
|
|
2206
|
+
req_id,
|
|
2207
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
2208
|
+
)
|
|
2209
|
+
|
|
2210
|
+
raw_model = params.get("model", "")
|
|
2211
|
+
if not raw_model or not isinstance(raw_model, str) or not raw_model.strip():
|
|
2212
|
+
return self.send_response(
|
|
2213
|
+
req_id,
|
|
2214
|
+
error={"code": -32602, "message": "Missing 'model' parameter"},
|
|
2215
|
+
)
|
|
2216
|
+
raw_model = raw_model.strip()
|
|
2217
|
+
|
|
2218
|
+
provider = params.get("provider")
|
|
2219
|
+
if isinstance(provider, str):
|
|
2220
|
+
provider = provider.strip() or None
|
|
2221
|
+
|
|
2222
|
+
if "/" in raw_model:
|
|
2223
|
+
prov_part, model_name = raw_model.split("/", 1)
|
|
2224
|
+
provider = provider or prov_part.strip()
|
|
2225
|
+
model_name = model_name.strip()
|
|
2226
|
+
else:
|
|
2227
|
+
model_name = raw_model
|
|
2228
|
+
if not provider:
|
|
2229
|
+
if model_name.startswith("gemini-"):
|
|
2230
|
+
provider = "antigravity"
|
|
2231
|
+
elif "deepseek" in model_name:
|
|
2232
|
+
provider = "deepseek"
|
|
2233
|
+
elif "gpt-" in model_name or "o1" in model_name or "o3" in model_name:
|
|
2234
|
+
provider = "openai"
|
|
2235
|
+
elif "claude-" in model_name:
|
|
2236
|
+
provider = "anthropic"
|
|
2237
|
+
else:
|
|
2238
|
+
current_llm = getattr(self.agent.agent, "llm", None)
|
|
2239
|
+
current_config = getattr(current_llm, "config", None)
|
|
2240
|
+
if current_config and hasattr(current_config, "provider"):
|
|
2241
|
+
provider = current_config.provider
|
|
2242
|
+
elif self.settings and self.settings.default_provider:
|
|
2243
|
+
provider = self.settings.default_provider
|
|
2244
|
+
else:
|
|
2245
|
+
provider = "openai"
|
|
2246
|
+
|
|
2247
|
+
# 更新 Agent 当前模型标识
|
|
2248
|
+
self.agent.agent.model = model_name
|
|
2249
|
+
|
|
2250
|
+
llm_inst = getattr(self.agent.agent, "llm", None)
|
|
2251
|
+
if hasattr(llm_inst, "config"):
|
|
2252
|
+
paths = self.paths or AgentPaths()
|
|
2253
|
+
auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
|
|
2254
|
+
api_key = None
|
|
2255
|
+
base_url = None
|
|
2256
|
+
if provider:
|
|
2257
|
+
# 1. 优先从全局凭据中心读取
|
|
2258
|
+
cred = auth_mgr.get_credential(provider)
|
|
2259
|
+
if cred is not None:
|
|
2260
|
+
if isinstance(cred, ApiKeyCredential):
|
|
2261
|
+
api_key = cred.resolve_key()
|
|
2262
|
+
if cred.base_url:
|
|
2263
|
+
base_url = cred.base_url
|
|
2264
|
+
elif isinstance(cred, OAuthCredential):
|
|
2265
|
+
api_key = cred.access
|
|
2266
|
+
|
|
2267
|
+
# 2. 次选环境变量兜底
|
|
2268
|
+
if not api_key:
|
|
2269
|
+
api_key = os.environ.get(f"{provider.upper()}_API_KEY")
|
|
2270
|
+
base_url = os.environ.get(f"{provider.upper()}_BASE_URL")
|
|
2271
|
+
try:
|
|
2272
|
+
new_config = Config(
|
|
2273
|
+
provider=provider or "openai",
|
|
2274
|
+
model=model_name,
|
|
2275
|
+
api_key=api_key or "placeholder",
|
|
2276
|
+
base_url=base_url,
|
|
2277
|
+
)
|
|
2278
|
+
self.agent.agent.llm = LLM(config=new_config)
|
|
2279
|
+
except Exception:
|
|
2280
|
+
pass
|
|
2281
|
+
elif llm_inst is not None and hasattr(llm_inst, "model"):
|
|
2282
|
+
setattr(llm_inst, "model", model_name)
|
|
2283
|
+
|
|
2284
|
+
# 向 Session 追加 ModelChangeEntry
|
|
2285
|
+
entry = ModelChangeEntry(
|
|
2286
|
+
model=model_name,
|
|
2287
|
+
provider=provider,
|
|
2288
|
+
parent_id=self.agent.session.tree.current_id,
|
|
2289
|
+
)
|
|
2290
|
+
self.agent.session.append_entry(entry)
|
|
2291
|
+
|
|
2292
|
+
# 若 persist=True,更新并持久化 settings.json
|
|
2293
|
+
persist = bool(params.get("persist", False))
|
|
2294
|
+
if persist:
|
|
2295
|
+
paths = self.paths or AgentPaths()
|
|
2296
|
+
if self.settings is None:
|
|
2297
|
+
self.settings = load_settings(paths)
|
|
2298
|
+
self.settings.default_model = model_name
|
|
2299
|
+
if provider:
|
|
2300
|
+
self.settings.default_provider = provider
|
|
2301
|
+
save_settings(self.settings, paths.settings_path)
|
|
2302
|
+
|
|
2303
|
+
return self.send_response(
|
|
2304
|
+
req_id,
|
|
2305
|
+
result={
|
|
2306
|
+
"status": "ok",
|
|
2307
|
+
"model": model_name,
|
|
2308
|
+
"provider": provider,
|
|
2309
|
+
},
|
|
2310
|
+
)
|
|
2311
|
+
|
|
2312
|
+
def _handle_thinking_set(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2313
|
+
if not self.agent:
|
|
2314
|
+
return self.send_response(
|
|
2315
|
+
req_id,
|
|
2316
|
+
error={"code": -32001, "message": "Agent not initialized"},
|
|
2317
|
+
)
|
|
2318
|
+
|
|
2319
|
+
level = params.get("level", "")
|
|
2320
|
+
if not level or not isinstance(level, str):
|
|
2321
|
+
return self.send_response(
|
|
2322
|
+
req_id,
|
|
2323
|
+
error={"code": -32602, "message": "Missing 'level' parameter"},
|
|
2324
|
+
)
|
|
2325
|
+
level = level.strip().lower()
|
|
2326
|
+
|
|
2327
|
+
valid_levels = {"off", "minimal", "low", "medium", "high", "xhigh", "max"}
|
|
2328
|
+
if level not in valid_levels:
|
|
2329
|
+
return self.send_response(
|
|
2330
|
+
req_id,
|
|
2331
|
+
error={
|
|
2332
|
+
"code": -32602,
|
|
2333
|
+
"message": f"Invalid thinking level '{level}'. Must be one of: {', '.join(sorted(valid_levels))}",
|
|
2334
|
+
},
|
|
2335
|
+
)
|
|
2336
|
+
|
|
2337
|
+
entry = ThinkingLevelChangeEntry(
|
|
2338
|
+
thinking_level=level,
|
|
2339
|
+
parent_id=self.agent.session.tree.current_id,
|
|
2340
|
+
)
|
|
2341
|
+
self.agent.session.append_entry(entry)
|
|
2342
|
+
|
|
2343
|
+
setattr(self.agent, "thinking_level", level)
|
|
2344
|
+
|
|
2345
|
+
persist = bool(params.get("persist", False))
|
|
2346
|
+
if persist:
|
|
2347
|
+
paths = self.paths or AgentPaths()
|
|
2348
|
+
if self.settings is None:
|
|
2349
|
+
self.settings = load_settings(paths)
|
|
2350
|
+
self.settings.default_thinking_level = level # type: ignore[assignment]
|
|
2351
|
+
save_settings(self.settings, paths.settings_path)
|
|
2352
|
+
|
|
2353
|
+
return self.send_response(
|
|
2354
|
+
req_id,
|
|
2355
|
+
result={
|
|
2356
|
+
"status": "ok",
|
|
2357
|
+
"level": level,
|
|
2358
|
+
},
|
|
2359
|
+
)
|
|
2360
|
+
|
|
2361
|
+
def _get_configured_providers(self) -> set[str]:
|
|
2362
|
+
configured: set[str] = set()
|
|
2363
|
+
paths = self.paths or AgentPaths()
|
|
2364
|
+
auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
|
|
2365
|
+
|
|
2366
|
+
openai_base_url = os.environ.get("OPENAI_BASE_URL", "").strip().lower()
|
|
2367
|
+
is_deepseek_proxy = "deepseek" in openai_base_url
|
|
2368
|
+
|
|
2369
|
+
for p in ["deepseek", "openai", "anthropic", "antigravity"]:
|
|
2370
|
+
if auth_mgr.get_credential(p) is not None:
|
|
2371
|
+
configured.add(p)
|
|
2372
|
+
continue
|
|
2373
|
+
if p == "openai" and is_deepseek_proxy:
|
|
2374
|
+
continue
|
|
2375
|
+
key_name = "ANTIGRAVITY_ACCESS_TOKEN" if p == "antigravity" else f"{p.upper()}_API_KEY"
|
|
2376
|
+
if os.environ.get(key_name):
|
|
2377
|
+
configured.add(p)
|
|
2378
|
+
continue
|
|
2379
|
+
if p == "deepseek" and is_deepseek_proxy and os.environ.get("OPENAI_API_KEY"):
|
|
2380
|
+
configured.add("deepseek")
|
|
2381
|
+
continue
|
|
2382
|
+
if p == "antigravity":
|
|
2383
|
+
try:
|
|
2384
|
+
from my_agent_llm.auth.antigravity import AntigravityAuthResolver
|
|
2385
|
+
|
|
2386
|
+
ws = Path(self.agent.workspace if self.agent else ".").resolve()
|
|
2387
|
+
resolver = AntigravityAuthResolver(workspace=ws)
|
|
2388
|
+
if resolver.resolve_credentials() is not None:
|
|
2389
|
+
configured.add("antigravity")
|
|
2390
|
+
except Exception:
|
|
2391
|
+
pass
|
|
2392
|
+
|
|
2393
|
+
return configured
|
|
2394
|
+
|
|
2395
|
+
def _handle_models_list(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2396
|
+
scope = params.get("scope", "configured")
|
|
2397
|
+
configured_providers = self._get_configured_providers()
|
|
2398
|
+
|
|
2399
|
+
models = []
|
|
2400
|
+
for item in KNOWN_MODEL_CATALOG:
|
|
2401
|
+
prov = item["provider"]
|
|
2402
|
+
is_configured = prov in configured_providers
|
|
2403
|
+
if scope == "all" or is_configured:
|
|
2404
|
+
models.append(
|
|
2405
|
+
{
|
|
2406
|
+
**item,
|
|
2407
|
+
"is_configured": is_configured,
|
|
2408
|
+
}
|
|
2409
|
+
)
|
|
2410
|
+
|
|
2411
|
+
# 动态补充对标 pi-antigravity 的模型目录
|
|
2412
|
+
is_antigravity_configured = "antigravity" in configured_providers
|
|
2413
|
+
if scope == "all" or is_antigravity_configured:
|
|
2414
|
+
for item in get_antigravity_catalog():
|
|
2415
|
+
models.append(
|
|
2416
|
+
{
|
|
2417
|
+
**item,
|
|
2418
|
+
"is_configured": is_antigravity_configured,
|
|
2419
|
+
}
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
# 动态补充对标 DeepSeek 的模型目录
|
|
2423
|
+
is_deepseek_configured = "deepseek" in configured_providers
|
|
2424
|
+
if scope == "all" or is_deepseek_configured:
|
|
2425
|
+
for item in get_deepseek_catalog():
|
|
2426
|
+
models.append(
|
|
2427
|
+
{
|
|
2428
|
+
**item,
|
|
2429
|
+
"is_configured": is_deepseek_configured,
|
|
2430
|
+
}
|
|
2431
|
+
)
|
|
2432
|
+
|
|
2433
|
+
# 动态补充环境变量中显式配置的自定义模型 (例如 OPENAI_MODEL=deepseek-flash 或 DEEPSEEK_MODEL)
|
|
2434
|
+
custom_model = os.environ.get("OPENAI_MODEL") or os.environ.get("DEEPSEEK_MODEL")
|
|
2435
|
+
if custom_model and not any(m["id"] == custom_model for m in models):
|
|
2436
|
+
prov = (
|
|
2437
|
+
"deepseek"
|
|
2438
|
+
if "deepseek" in configured_providers
|
|
2439
|
+
else ("openai" if "openai" in configured_providers else "default")
|
|
2440
|
+
)
|
|
2441
|
+
if prov in configured_providers or scope == "all":
|
|
2442
|
+
models.insert(
|
|
2443
|
+
0,
|
|
2444
|
+
{
|
|
2445
|
+
"id": custom_model,
|
|
2446
|
+
"provider": prov,
|
|
2447
|
+
"name": custom_model,
|
|
2448
|
+
"contextWindow": 64000 if prov == "deepseek" else 128000,
|
|
2449
|
+
"is_configured": prov in configured_providers,
|
|
2450
|
+
},
|
|
2451
|
+
)
|
|
2452
|
+
|
|
2453
|
+
curr = "default"
|
|
2454
|
+
if self.agent:
|
|
2455
|
+
curr = getattr(self.agent, "model", None) or getattr(getattr(self.agent, "agent", None), "model", "default")
|
|
2456
|
+
return self.send_response(
|
|
2457
|
+
req_id,
|
|
2458
|
+
result={
|
|
2459
|
+
"status": "ok",
|
|
2460
|
+
"scope": scope,
|
|
2461
|
+
"configured_providers": sorted(list(configured_providers)),
|
|
2462
|
+
"models": models,
|
|
2463
|
+
"current_model": curr,
|
|
2464
|
+
},
|
|
2465
|
+
)
|
|
2466
|
+
|
|
2467
|
+
def _handle_auth_logout(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2468
|
+
provider = params.get("provider", "")
|
|
2469
|
+
if not provider or not isinstance(provider, str) or not provider.strip():
|
|
2470
|
+
return self.send_response(
|
|
2471
|
+
req_id,
|
|
2472
|
+
error={"code": -32602, "message": "Missing 'provider' parameter"},
|
|
2473
|
+
)
|
|
2474
|
+
prov = provider.strip().lower()
|
|
2475
|
+
|
|
2476
|
+
paths = self.paths or AgentPaths()
|
|
2477
|
+
auth_mgr = self.auth_mgr or AuthManager(auth_path=paths.auth_path)
|
|
2478
|
+
self.auth_mgr = auth_mgr
|
|
2479
|
+
|
|
2480
|
+
profile = params.get("profile")
|
|
2481
|
+
if isinstance(profile, str):
|
|
2482
|
+
profile = profile.strip() or None
|
|
2483
|
+
|
|
2484
|
+
removed = auth_mgr.remove_credential(prov, profile=profile)
|
|
2485
|
+
|
|
2486
|
+
return self.send_response(
|
|
2487
|
+
req_id,
|
|
2488
|
+
result={
|
|
2489
|
+
"status": "ok",
|
|
2490
|
+
"provider": prov,
|
|
2491
|
+
"removed": removed,
|
|
2492
|
+
},
|
|
2493
|
+
)
|
|
2494
|
+
|
|
2495
|
+
def _handle_resource_reload(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2496
|
+
paths = self.paths or AgentPaths()
|
|
2497
|
+
workspace_path = Path(self.agent.workspace if self.agent else ".").resolve()
|
|
2498
|
+
|
|
2499
|
+
# 1. 重载 settings
|
|
2500
|
+
self.settings = load_settings(paths, cwd=workspace_path)
|
|
2501
|
+
|
|
2502
|
+
# 2. 重载项目指导文件与系统提示词
|
|
2503
|
+
new_prompt = build_default_coding_prompt(workspace_path)
|
|
2504
|
+
if self.agent:
|
|
2505
|
+
self.agent.agent._system_prompt = new_prompt
|
|
2506
|
+
|
|
2507
|
+
mem_store = getattr(self.agent.agent, "memory_store", None)
|
|
2508
|
+
mem_prompt = (
|
|
2509
|
+
mem_store.format_all_for_system_prompt()
|
|
2510
|
+
if mem_store is not None and hasattr(mem_store, "format_all_for_system_prompt")
|
|
2511
|
+
else None
|
|
2512
|
+
)
|
|
2513
|
+
skill_prompt = (
|
|
2514
|
+
self.agent.agent.skill_manager.format_prompt() if hasattr(self.agent.agent, "skill_manager") else ""
|
|
2515
|
+
)
|
|
2516
|
+
subagent_prompt = (
|
|
2517
|
+
self.agent.agent.subagent_manager.format_prompt()
|
|
2518
|
+
if hasattr(self.agent.agent, "subagent_manager")
|
|
2519
|
+
else ""
|
|
2520
|
+
)
|
|
2521
|
+
parts = [p for p in (new_prompt, skill_prompt, subagent_prompt, mem_prompt) if p]
|
|
2522
|
+
new_sys_content = "\n\n".join(parts)
|
|
2523
|
+
|
|
2524
|
+
if self.agent.agent.messages and self.agent.agent.messages[0].role == "system":
|
|
2525
|
+
self.agent.agent.messages[0] = Message(role="system", content=new_sys_content)
|
|
2526
|
+
elif parts:
|
|
2527
|
+
self.agent.agent.messages.insert(0, Message(role="system", content=new_sys_content))
|
|
2528
|
+
|
|
2529
|
+
# 3. 重载 skills
|
|
2530
|
+
skill_dirs = [
|
|
2531
|
+
paths.skills_dir,
|
|
2532
|
+
paths.project_skills_dir(workspace_path),
|
|
2533
|
+
paths.project_agents_skills_dir(workspace_path),
|
|
2534
|
+
]
|
|
2535
|
+
skill_mgr = SkillManager(dirs=skill_dirs)
|
|
2536
|
+
if self.agent and hasattr(self.agent.agent, "skill_manager"):
|
|
2537
|
+
self.agent.agent.skill_manager = skill_mgr
|
|
2538
|
+
skill_count = len(skill_mgr.skills)
|
|
2539
|
+
|
|
2540
|
+
# 4. 统计 templates
|
|
2541
|
+
template_count = 0
|
|
2542
|
+
for p_dir in (
|
|
2543
|
+
paths.prompts_dir,
|
|
2544
|
+
paths.project_agent_dir(workspace_path) / "prompts",
|
|
2545
|
+
workspace_path / ".agents" / "prompts",
|
|
2546
|
+
):
|
|
2547
|
+
if p_dir.exists():
|
|
2548
|
+
template_count += len(list(p_dir.glob("*.md")))
|
|
2549
|
+
|
|
2550
|
+
summary = f"Reloaded settings, project context, {skill_count} skills, and {template_count} prompt templates."
|
|
2551
|
+
return self.send_response(
|
|
2552
|
+
req_id,
|
|
2553
|
+
result={
|
|
2554
|
+
"status": "ok",
|
|
2555
|
+
"summary": summary,
|
|
2556
|
+
"skills_count": skill_count,
|
|
2557
|
+
"templates_count": template_count,
|
|
2558
|
+
},
|
|
2559
|
+
)
|
|
2560
|
+
|
|
2561
|
+
def _handle_settings_get(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2562
|
+
paths = self.paths or AgentPaths()
|
|
2563
|
+
ws = Path(self.agent.workspace if self.agent else ".").resolve()
|
|
2564
|
+
if self.settings is None:
|
|
2565
|
+
self.settings = load_settings(paths, cwd=ws)
|
|
2566
|
+
|
|
2567
|
+
return self.send_response(
|
|
2568
|
+
req_id,
|
|
2569
|
+
result={
|
|
2570
|
+
"status": "ok",
|
|
2571
|
+
"settings": self.settings.model_dump(),
|
|
2572
|
+
"paths": {
|
|
2573
|
+
"global_settings": str(paths.settings_path),
|
|
2574
|
+
"project_settings": str(paths.project_settings_path(ws)),
|
|
2575
|
+
},
|
|
2576
|
+
},
|
|
2577
|
+
)
|
|
2578
|
+
|
|
2579
|
+
def _handle_settings_set(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2580
|
+
paths = self.paths or AgentPaths()
|
|
2581
|
+
ws = Path(self.agent.workspace if self.agent else ".").resolve()
|
|
2582
|
+
if self.settings is None:
|
|
2583
|
+
self.settings = load_settings(paths, cwd=ws)
|
|
2584
|
+
|
|
2585
|
+
scope = params.get("scope", "global")
|
|
2586
|
+
target_path = paths.project_settings_path(ws) if scope == "project" else paths.settings_path
|
|
2587
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2588
|
+
|
|
2589
|
+
updated_fields: dict[str, Any] = {}
|
|
2590
|
+
for key in [
|
|
2591
|
+
"default_model",
|
|
2592
|
+
"default_provider",
|
|
2593
|
+
"default_thinking_level",
|
|
2594
|
+
"default_permission_mode",
|
|
2595
|
+
"theme",
|
|
2596
|
+
"auto_compact",
|
|
2597
|
+
]:
|
|
2598
|
+
if key in params:
|
|
2599
|
+
val = params[key]
|
|
2600
|
+
setattr(self.settings, key, val)
|
|
2601
|
+
updated_fields[key] = val
|
|
2602
|
+
|
|
2603
|
+
save_settings(self.settings, target_path)
|
|
2604
|
+
|
|
2605
|
+
return self.send_response(
|
|
2606
|
+
req_id,
|
|
2607
|
+
result={
|
|
2608
|
+
"status": "ok",
|
|
2609
|
+
"updated": updated_fields,
|
|
2610
|
+
"settings": self.settings.model_dump(),
|
|
2611
|
+
},
|
|
2612
|
+
)
|
|
2613
|
+
|
|
2614
|
+
def _handle_trust_set(self, req_id: Any, params: dict[str, Any]) -> dict[str, Any]:
|
|
2615
|
+
paths = self.paths or AgentPaths()
|
|
2616
|
+
paths.home.mkdir(parents=True, exist_ok=True)
|
|
2617
|
+
trust_file = paths.home / "trust.json"
|
|
2618
|
+
|
|
2619
|
+
workspace_path = Path(self.agent.workspace if self.agent else ".").resolve()
|
|
2620
|
+
target_dir = Path(params.get("path", workspace_path)).resolve()
|
|
2621
|
+
|
|
2622
|
+
if bool(params.get("parent", False)):
|
|
2623
|
+
target_dir = target_dir.parent
|
|
2624
|
+
|
|
2625
|
+
trusted = bool(params.get("trusted", True))
|
|
2626
|
+
|
|
2627
|
+
trust_data: dict[str, bool] = {}
|
|
2628
|
+
if trust_file.exists():
|
|
2629
|
+
try:
|
|
2630
|
+
content = trust_file.read_text(encoding="utf-8")
|
|
2631
|
+
parsed = json.loads(content)
|
|
2632
|
+
if isinstance(parsed, dict):
|
|
2633
|
+
trust_data = parsed
|
|
2634
|
+
except Exception:
|
|
2635
|
+
trust_data = {}
|
|
2636
|
+
|
|
2637
|
+
trust_data[str(target_dir)] = trusted
|
|
2638
|
+
|
|
2639
|
+
tmp_file = trust_file.with_name(f"{trust_file.name}.tmp")
|
|
2640
|
+
tmp_file.write_text(json.dumps(trust_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
2641
|
+
tmp_file.replace(trust_file)
|
|
2642
|
+
|
|
2643
|
+
decision = "trusted" if trusted else "untrusted"
|
|
2644
|
+
return self.send_response(
|
|
2645
|
+
req_id,
|
|
2646
|
+
result={
|
|
2647
|
+
"status": "ok",
|
|
2648
|
+
"path": str(target_dir),
|
|
2649
|
+
"trusted": trusted,
|
|
2650
|
+
"decision": decision,
|
|
2651
|
+
},
|
|
2652
|
+
)
|
|
2653
|
+
|
|
2654
|
+
async def _handle_shutdown(self, req_id: Any) -> dict[str, Any]:
|
|
2655
|
+
self.is_shutting_down = True
|
|
2656
|
+
if self.agent and hasattr(self.agent, "close_mcp"):
|
|
2657
|
+
res = self.agent.close_mcp()
|
|
2658
|
+
if inspect.isawaitable(res):
|
|
2659
|
+
await res
|
|
2660
|
+
return self.send_response(req_id, result={"status": "ok"})
|
|
2661
|
+
|
|
2662
|
+
async def handle_request(self, req: dict[str, Any]) -> dict[str, Any]:
|
|
2663
|
+
"""分发并处理单个 RPC 请求。"""
|
|
2664
|
+
req_id = req.get("id", 0)
|
|
2665
|
+
method = req.get("method", "")
|
|
2666
|
+
params = req.get("params", {})
|
|
2667
|
+
|
|
2668
|
+
try:
|
|
2669
|
+
if method == "initialize":
|
|
2670
|
+
return await self._handle_initialize(req_id, params)
|
|
2671
|
+
elif method == "prompt":
|
|
2672
|
+
return await self._handle_prompt(req_id, params)
|
|
2673
|
+
elif method == "login":
|
|
2674
|
+
return self._handle_login(req_id, params)
|
|
2675
|
+
elif method == "steer":
|
|
2676
|
+
return self._handle_steer(req_id, params)
|
|
2677
|
+
elif method == "followup":
|
|
2678
|
+
return self._handle_followup(req_id, params)
|
|
2679
|
+
elif method == "abort":
|
|
2680
|
+
return self._handle_abort(req_id)
|
|
2681
|
+
elif method == "session_name":
|
|
2682
|
+
return self._handle_session_name(req_id, params)
|
|
2683
|
+
elif method == "session_list":
|
|
2684
|
+
return self._handle_session_list(req_id, params)
|
|
2685
|
+
elif method == "session_resume":
|
|
2686
|
+
return self._handle_session_resume(req_id, params)
|
|
2687
|
+
elif method == "session_delete":
|
|
2688
|
+
return self._handle_session_delete(req_id, params)
|
|
2689
|
+
elif method == "session_history":
|
|
2690
|
+
return self._handle_session_history(req_id, params)
|
|
2691
|
+
elif method == "session_stats":
|
|
2692
|
+
return self._handle_session_stats(req_id, params)
|
|
2693
|
+
elif method == "session_compact":
|
|
2694
|
+
return await self._handle_session_compact(req_id, params)
|
|
2695
|
+
elif method == "session_new":
|
|
2696
|
+
return self._handle_session_new(req_id, params)
|
|
2697
|
+
elif method == "session_tree":
|
|
2698
|
+
return self._handle_session_tree(req_id, params)
|
|
2699
|
+
elif method == "session_branch":
|
|
2700
|
+
return await self._handle_session_branch(req_id, params)
|
|
2701
|
+
elif method == "session_fork":
|
|
2702
|
+
return self._handle_session_fork(req_id, params)
|
|
2703
|
+
elif method == "session_clone":
|
|
2704
|
+
return self._handle_session_clone(req_id, params)
|
|
2705
|
+
elif method == "shell_exec":
|
|
2706
|
+
return await self._handle_shell_exec(req_id, params)
|
|
2707
|
+
elif method == "macro_expand":
|
|
2708
|
+
return self._handle_macro_expand(req_id, params)
|
|
2709
|
+
elif method == "models_list":
|
|
2710
|
+
return self._handle_models_list(req_id, params)
|
|
2711
|
+
elif method == "model_switch":
|
|
2712
|
+
return self._handle_model_switch(req_id, params)
|
|
2713
|
+
elif method == "thinking_set":
|
|
2714
|
+
return self._handle_thinking_set(req_id, params)
|
|
2715
|
+
elif method == "auth_logout":
|
|
2716
|
+
return self._handle_auth_logout(req_id, params)
|
|
2717
|
+
elif method == "resource_reload":
|
|
2718
|
+
return self._handle_resource_reload(req_id, params)
|
|
2719
|
+
elif method == "settings_get":
|
|
2720
|
+
return self._handle_settings_get(req_id, params)
|
|
2721
|
+
elif method == "settings_set":
|
|
2722
|
+
return self._handle_settings_set(req_id, params)
|
|
2723
|
+
elif method == "trust_set":
|
|
2724
|
+
return self._handle_trust_set(req_id, params)
|
|
2725
|
+
elif method == "shutdown":
|
|
2726
|
+
return await self._handle_shutdown(req_id)
|
|
2727
|
+
else:
|
|
2728
|
+
return self.send_response(
|
|
2729
|
+
req_id,
|
|
2730
|
+
error={"code": -32601, "message": f"Method '{method}' not found"},
|
|
2731
|
+
)
|
|
2732
|
+
except Exception as e:
|
|
2733
|
+
return self.send_response(
|
|
2734
|
+
req_id,
|
|
2735
|
+
error={"code": -32000, "message": str(e)},
|
|
2736
|
+
)
|
|
2737
|
+
|
|
2738
|
+
async def run_forever(self) -> None:
|
|
2739
|
+
"""主服务循环,以异步方式按行消费 stdin 并处理请求。"""
|
|
2740
|
+
while not self.is_shutting_down:
|
|
2741
|
+
try:
|
|
2742
|
+
line = await asyncio.to_thread(self.stdin.readline)
|
|
2743
|
+
except Exception:
|
|
2744
|
+
break
|
|
2745
|
+
|
|
2746
|
+
if not line:
|
|
2747
|
+
# 管道关闭 (EOF)
|
|
2748
|
+
break
|
|
2749
|
+
|
|
2750
|
+
line_str = line.strip()
|
|
2751
|
+
if not line_str:
|
|
2752
|
+
continue
|
|
2753
|
+
|
|
2754
|
+
try:
|
|
2755
|
+
req = json.loads(line_str)
|
|
2756
|
+
except json.JSONDecodeError:
|
|
2757
|
+
self.send_response(
|
|
2758
|
+
0,
|
|
2759
|
+
error={"code": -32700, "message": "Parse error (invalid JSON)"},
|
|
2760
|
+
)
|
|
2761
|
+
continue
|
|
2762
|
+
|
|
2763
|
+
# 并发派发请求,保证在 prompt 执行期间仍可实时处理 abort / steer / followup
|
|
2764
|
+
task = asyncio.create_task(self.handle_request(req))
|
|
2765
|
+
self._background_tasks.add(task)
|
|
2766
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
2767
|
+
|
|
2768
|
+
if self._background_tasks:
|
|
2769
|
+
await asyncio.gather(*list(self._background_tasks), return_exceptions=True)
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
async def main() -> None:
|
|
2773
|
+
load_dotenv(find_dotenv(usecwd=True))
|
|
2774
|
+
parser = argparse.ArgumentParser(description="my-coding-agent stdio JSON-RPC server")
|
|
2775
|
+
parser.add_argument("-w", "--workspace", default=".", help="工作区路径")
|
|
2776
|
+
parser.add_argument("-m", "--model", default=None, help="LLM 模型标识符")
|
|
2777
|
+
parser.add_argument("-c", "--continue", dest="continue_session", action="store_true", help="续接最近一次会话")
|
|
2778
|
+
parser.add_argument("-r", "--resume", nargs="?", const=True, default=None, help="恢复指定会话或打开选择器")
|
|
2779
|
+
parser.add_argument("-n", "--name", default=None, help="为当前会话命名")
|
|
2780
|
+
parser.add_argument("--thinking", default=None, help="思考深度等级")
|
|
2781
|
+
parser.add_argument("--no-session", action="store_true", help="内存无痕模式")
|
|
2782
|
+
parser.add_argument("--new-session", action="store_true", help="强制开启新会话")
|
|
2783
|
+
args = parser.parse_args()
|
|
2784
|
+
|
|
2785
|
+
server = RpcServer()
|
|
2786
|
+
# 如果指定了启动工作区或模型,先行执行预初始化
|
|
2787
|
+
if (
|
|
2788
|
+
args.workspace != "."
|
|
2789
|
+
or args.model is not None
|
|
2790
|
+
or args.continue_session
|
|
2791
|
+
or args.resume is not None
|
|
2792
|
+
or args.name is not None
|
|
2793
|
+
or args.thinking is not None
|
|
2794
|
+
or args.no_session
|
|
2795
|
+
):
|
|
2796
|
+
await server.handle_request(
|
|
2797
|
+
{
|
|
2798
|
+
"jsonrpc": "2.0",
|
|
2799
|
+
"id": 0,
|
|
2800
|
+
"method": "initialize",
|
|
2801
|
+
"params": {
|
|
2802
|
+
"workspace": args.workspace,
|
|
2803
|
+
"model": args.model,
|
|
2804
|
+
"continue_session": args.continue_session,
|
|
2805
|
+
"resume": args.resume,
|
|
2806
|
+
"name": args.name,
|
|
2807
|
+
"thinking": args.thinking,
|
|
2808
|
+
"no_session": args.no_session,
|
|
2809
|
+
},
|
|
2810
|
+
}
|
|
2811
|
+
)
|
|
2812
|
+
|
|
2813
|
+
await server.run_forever()
|
|
2814
|
+
|
|
2815
|
+
|
|
2816
|
+
if __name__ == "__main__":
|
|
2817
|
+
asyncio.run(main())
|