deep-agent-cli 0.1.0__py3-none-any.whl
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.
- agent/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/factory.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Assembly entry: create_deep_agent() only. Do not fork Deep Agents core.
|
|
2
|
+
# Middleware augments the Deep Agents defaults; create_deep_agent remains the only assembly entry.
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from deepagents import (
|
|
9
|
+
GeneralPurposeSubagentProfile,
|
|
10
|
+
HarnessProfile,
|
|
11
|
+
create_deep_agent,
|
|
12
|
+
register_harness_profile,
|
|
13
|
+
)
|
|
14
|
+
from deepagents.backends.protocol import BackendProtocol, SandboxBackendProtocol
|
|
15
|
+
from deepagents.middleware.summarization import (
|
|
16
|
+
SummarizationToolMiddleware,
|
|
17
|
+
create_summarization_tool_middleware,
|
|
18
|
+
)
|
|
19
|
+
from deepagents._models import get_model_identifier, get_model_provider
|
|
20
|
+
from langchain.agents.middleware import TodoListMiddleware
|
|
21
|
+
from langchain_core.language_models import BaseChatModel
|
|
22
|
+
from langchain_core.tools import BaseTool
|
|
23
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
24
|
+
|
|
25
|
+
from agent.config import SandboxConfig, Settings
|
|
26
|
+
from agent.control import RunController
|
|
27
|
+
from agent.llm import build_chat_model
|
|
28
|
+
from agent.middleware.cancel_tools import ToolCancelMiddleware
|
|
29
|
+
from agent.middleware.attachments import AttachmentMaterializationMiddleware
|
|
30
|
+
from agent.middleware.pause import PauseGateMiddleware
|
|
31
|
+
from agent.middleware.recovery import RecoveryContextMiddleware
|
|
32
|
+
from agent.middleware.steering import SteeringMiddleware
|
|
33
|
+
from agent.middleware.tool_arg_hints import ToolArgHintMiddleware
|
|
34
|
+
from agent.middleware.write_operation import WriteOperationMiddleware
|
|
35
|
+
from agent.middleware.workspace_filesystem import WorkspaceFilesystemMiddleware
|
|
36
|
+
from agent.permission import (
|
|
37
|
+
PermissionMode, allow_mode_unavailable_reason, interrupt_on_for_mode,
|
|
38
|
+
permission_mode_from_interrupt_on,
|
|
39
|
+
)
|
|
40
|
+
from agent.sandbox import ExecutionMode, SKILLS_ROOT, WorkspaceCompositeBackend, select_backend
|
|
41
|
+
from agent.tools.execute import build_execute_tool
|
|
42
|
+
from agent.tools.human_input import build_human_input_tools
|
|
43
|
+
from agent.tools.web_search import build_web_search_tool
|
|
44
|
+
|
|
45
|
+
DEFAULT_FS_TOOLS = ["ls", "read_file", "glob", "grep", "write_file", "edit_file", "delete"]
|
|
46
|
+
DEFAULT_SYSTEM_PROMPT = (
|
|
47
|
+
"你是使用 {model_name} 的 Coding Agent CLI。"
|
|
48
|
+
"完成用户请求后直接报告结果。"
|
|
49
|
+
"如果继续执行需要用户提供信息或从多个选项中做决定,调用 request_human_input 暂停等待回答。"
|
|
50
|
+
"如果你主动给用户列出多个后续操作供其选择,也必须调用 request_human_input,"
|
|
51
|
+
"用 fields 的 single_select 或 multi_select 表达选项;不要只在普通回复中写编号菜单。"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AgentSpec:
|
|
57
|
+
instructions: str | None = None
|
|
58
|
+
tools: tuple[BaseTool, ...] = ()
|
|
59
|
+
skills: tuple[str, ...] | None = None
|
|
60
|
+
backend: BackendProtocol | None = None
|
|
61
|
+
sandbox: SandboxConfig | None = None
|
|
62
|
+
web_search_api_key: str | None = field(default=None, repr=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class PreparedAgent:
|
|
67
|
+
graph: Any
|
|
68
|
+
backend: BackendProtocol
|
|
69
|
+
model: BaseChatModel | None = None
|
|
70
|
+
spec: AgentSpec = field(default_factory=AgentSpec)
|
|
71
|
+
interrupt_on: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
system_prompt: str = ""
|
|
73
|
+
exposed_tool_names: list[str] = field(default_factory=list)
|
|
74
|
+
filesystem_tools: list[str] = field(default_factory=list)
|
|
75
|
+
execution_mode: ExecutionMode = ExecutionMode.CUSTOM
|
|
76
|
+
security_warning: str = ""
|
|
77
|
+
checkpointer: Any = None
|
|
78
|
+
run_controller: RunController | None = None
|
|
79
|
+
pause_condition: Callable[[], bool] = field(default=lambda: False)
|
|
80
|
+
compact_middleware: SummarizationToolMiddleware | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compose_system_prompt(spec: AgentSpec, model: BaseChatModel, workspace: Path) -> str:
|
|
84
|
+
model_name = str(getattr(model, "model_name", None) or get_model_identifier(model) or type(model).__name__)
|
|
85
|
+
sections = [DEFAULT_SYSTEM_PROMPT.format(model_name=model_name)]
|
|
86
|
+
if spec.instructions and spec.instructions.strip():
|
|
87
|
+
sections.append(f"# User Instructions\n{spec.instructions.strip()}")
|
|
88
|
+
project_instructions = workspace.expanduser().resolve() / "AGENTS.md"
|
|
89
|
+
if project_instructions.is_file():
|
|
90
|
+
sections.append(f"# Project Instructions\n{project_instructions.read_text(encoding='utf-8')}")
|
|
91
|
+
return "\n\n".join(sections)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_agent(
|
|
95
|
+
spec: AgentSpec,
|
|
96
|
+
model: BaseChatModel,
|
|
97
|
+
permission: PermissionMode,
|
|
98
|
+
checkpointer: Any | None,
|
|
99
|
+
run_controller: RunController,
|
|
100
|
+
should_pause: Callable[[], bool],
|
|
101
|
+
interrupt_on_override: dict[str, Any] | None = None,
|
|
102
|
+
) -> PreparedAgent:
|
|
103
|
+
sandbox_cfg = spec.sandbox or SandboxConfig()
|
|
104
|
+
if spec.backend is None:
|
|
105
|
+
selected = select_backend(sandbox_cfg)
|
|
106
|
+
fs_backend = selected.backend
|
|
107
|
+
execution_mode = selected.mode
|
|
108
|
+
security_warning = selected.warning
|
|
109
|
+
else:
|
|
110
|
+
fs_backend = spec.backend
|
|
111
|
+
execution_mode = ExecutionMode.CUSTOM
|
|
112
|
+
security_warning = ""
|
|
113
|
+
if permission is PermissionMode.ALLOW and execution_mode is not ExecutionMode.SANDBOXED:
|
|
114
|
+
raise ValueError(allow_mode_unavailable_reason(execution_mode))
|
|
115
|
+
_disable_general_purpose_task(model)
|
|
116
|
+
prompt = compose_system_prompt(spec, model, sandbox_cfg.workspace)
|
|
117
|
+
tools = [*build_human_input_tools(), *spec.tools]
|
|
118
|
+
if spec.web_search_api_key:
|
|
119
|
+
tools.append(build_web_search_tool(spec.web_search_api_key))
|
|
120
|
+
hitl = dict(interrupt_on_override) if interrupt_on_override is not None else (interrupt_on_for_mode(permission) or {})
|
|
121
|
+
filesystem_tools = list(DEFAULT_FS_TOOLS)
|
|
122
|
+
supports_execute = isinstance(fs_backend, SandboxBackendProtocol)
|
|
123
|
+
if supports_execute:
|
|
124
|
+
tools.append(build_execute_tool(
|
|
125
|
+
fs_backend,
|
|
126
|
+
network_by_default=permission is PermissionMode.ALLOW,
|
|
127
|
+
))
|
|
128
|
+
skill_sources = list(spec.skills) if spec.skills is not None else (
|
|
129
|
+
_default_skill_sources(fs_backend) if spec.backend is None else None
|
|
130
|
+
)
|
|
131
|
+
saver = checkpointer if checkpointer is not None else InMemorySaver()
|
|
132
|
+
compact_middleware = create_summarization_tool_middleware(model, fs_backend)
|
|
133
|
+
graph = create_deep_agent(
|
|
134
|
+
model=model,
|
|
135
|
+
tools=tools,
|
|
136
|
+
system_prompt=prompt,
|
|
137
|
+
middleware=[
|
|
138
|
+
PauseGateMiddleware(lambda: run_controller.pause_requested or should_pause()),
|
|
139
|
+
RecoveryContextMiddleware(),
|
|
140
|
+
SteeringMiddleware(run_controller),
|
|
141
|
+
ToolCancelMiddleware(run_controller),
|
|
142
|
+
ToolArgHintMiddleware(),
|
|
143
|
+
WriteOperationMiddleware(fs_backend),
|
|
144
|
+
AttachmentMaterializationMiddleware(),
|
|
145
|
+
TodoListMiddleware(),
|
|
146
|
+
WorkspaceFilesystemMiddleware(backend=fs_backend, tools=filesystem_tools),
|
|
147
|
+
compact_middleware,
|
|
148
|
+
],
|
|
149
|
+
skills=skill_sources,
|
|
150
|
+
backend=fs_backend,
|
|
151
|
+
interrupt_on=hitl or None,
|
|
152
|
+
checkpointer=saver,
|
|
153
|
+
name="deep-agent-template",
|
|
154
|
+
)
|
|
155
|
+
return PreparedAgent(
|
|
156
|
+
graph=graph,
|
|
157
|
+
backend=fs_backend,
|
|
158
|
+
model=model,
|
|
159
|
+
spec=spec,
|
|
160
|
+
interrupt_on=hitl,
|
|
161
|
+
system_prompt=prompt,
|
|
162
|
+
exposed_tool_names=[tool.name for tool in tools if tool.name != "request_human_input"],
|
|
163
|
+
filesystem_tools=filesystem_tools,
|
|
164
|
+
execution_mode=execution_mode,
|
|
165
|
+
security_warning=security_warning,
|
|
166
|
+
checkpointer=saver,
|
|
167
|
+
run_controller=run_controller,
|
|
168
|
+
pause_condition=should_pause,
|
|
169
|
+
compact_middleware=compact_middleware,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def create_agent(
|
|
174
|
+
*,
|
|
175
|
+
model: BaseChatModel | None = None,
|
|
176
|
+
checkpointer: Any | None = None,
|
|
177
|
+
should_pause: Callable[[], bool] | None = None,
|
|
178
|
+
instructions: str | None = None,
|
|
179
|
+
extra_tools: list[BaseTool] | None = None,
|
|
180
|
+
interrupt_on: dict[str, Any] | None = None,
|
|
181
|
+
skills: list[str] | None = None,
|
|
182
|
+
settings: Settings | None = None,
|
|
183
|
+
backend: BackendProtocol | None = None,
|
|
184
|
+
sandbox_config: SandboxConfig | None = None,
|
|
185
|
+
run_controller: RunController | None = None,
|
|
186
|
+
) -> PreparedAgent:
|
|
187
|
+
cfg = settings or Settings()
|
|
188
|
+
spec = AgentSpec(
|
|
189
|
+
instructions=instructions if instructions is not None else cfg.agent_instructions,
|
|
190
|
+
tools=tuple(extra_tools or ()),
|
|
191
|
+
skills=tuple(skills) if skills is not None else None,
|
|
192
|
+
backend=backend,
|
|
193
|
+
sandbox=sandbox_config or cfg.sandbox,
|
|
194
|
+
web_search_api_key=os.environ.get("TAVILY_API_KEY") or cfg.tavily_api_key,
|
|
195
|
+
)
|
|
196
|
+
permission = permission_mode_from_interrupt_on(interrupt_on) if interrupt_on is not None else PermissionMode.ASK
|
|
197
|
+
effective_interrupt_on = interrupt_on
|
|
198
|
+
if interrupt_on:
|
|
199
|
+
defaults = interrupt_on_for_mode(PermissionMode.ASK) or {}
|
|
200
|
+
for name, rule in defaults.items():
|
|
201
|
+
if name in interrupt_on and interrupt_on[name] != rule:
|
|
202
|
+
raise ValueError(f"Cannot override the default approval rule for {name}")
|
|
203
|
+
effective_interrupt_on = {**defaults, **interrupt_on}
|
|
204
|
+
return build_agent(
|
|
205
|
+
spec, model or _default_model(cfg), permission, checkpointer,
|
|
206
|
+
run_controller or RunController(), should_pause or (lambda: False),
|
|
207
|
+
interrupt_on_override=effective_interrupt_on,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _default_model(cfg: Settings) -> BaseChatModel:
|
|
212
|
+
return build_chat_model(cfg.active_profile, streaming=True)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _default_skill_sources(backend: BackendProtocol) -> list[str] | None:
|
|
216
|
+
if isinstance(backend, WorkspaceCompositeBackend) and backend.skills_dir is not None:
|
|
217
|
+
return [f"{SKILLS_ROOT}/"]
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_NO_GP = HarnessProfile(general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _disable_general_purpose_task(model: BaseChatModel) -> None:
|
|
225
|
+
provider = get_model_provider(model)
|
|
226
|
+
identifier = get_model_identifier(model)
|
|
227
|
+
if identifier:
|
|
228
|
+
key = identifier if ":" in identifier or not provider else f"{provider}:{identifier}"
|
|
229
|
+
register_harness_profile(key, _NO_GP)
|
|
230
|
+
elif provider:
|
|
231
|
+
# Models without an identifier cannot be targeted more narrowly upstream.
|
|
232
|
+
register_harness_profile(provider, _NO_GP)
|
agent/file_mutation.py
ADDED
agent/llm.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Qwen Responses compatibility without patching ``langchain-openai`` globally."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import AsyncIterator, Iterator
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
import langchain_openai.chat_models.base as _lc_base
|
|
9
|
+
from langchain_core.messages import BaseMessage, HumanMessage
|
|
10
|
+
from langchain_core.messages.content import create_image_block, create_text_block
|
|
11
|
+
from langchain_core.outputs import ChatGenerationChunk
|
|
12
|
+
from langchain_openai import ChatOpenAI
|
|
13
|
+
from pydantic import PrivateAttr, model_validator
|
|
14
|
+
|
|
15
|
+
from agent.attachments import ATTACHMENT_META_KEY, AttachmentStore, refs_from_message
|
|
16
|
+
from agent.config import ModelProfile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_QWEN_REASONING_EVENT_TYPES = {
|
|
20
|
+
"response.reasoning_text.delta": "response.reasoning_summary_text.delta",
|
|
21
|
+
# 1.6.x does not consume this event yet, but normalize it without inventing
|
|
22
|
+
# any fields in case an upstream converter starts doing so.
|
|
23
|
+
"response.reasoning_text.done": "response.reasoning_summary_text.done",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResponsesEventProxy:
|
|
28
|
+
"""Read-only view over an OpenAI SDK event; the source object is untouched."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_event", "_type")
|
|
31
|
+
|
|
32
|
+
def __init__(self, event: Any, event_type: str) -> None:
|
|
33
|
+
self._event = event
|
|
34
|
+
self._type = event_type
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def type(self) -> str:
|
|
38
|
+
return self._type
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def summary_index(self) -> int:
|
|
42
|
+
# Qwen's streamed reasoning is the final summary. content_index has a
|
|
43
|
+
# different Responses meaning and must not be reused here.
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
def __getattr__(self, name: str) -> Any:
|
|
47
|
+
return getattr(self._event, name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_qwen_responses_event(event: Any) -> Any:
|
|
51
|
+
"""Normalize only Qwen's reasoning event spelling for LangChain 1.6.x."""
|
|
52
|
+
event_type = _QWEN_REASONING_EVENT_TYPES.get(getattr(event, "type", None))
|
|
53
|
+
return event if event_type is None else ResponsesEventProxy(event, event_type)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def materialize_attachment_refs(input_: Any, store: AttachmentStore | None) -> Any:
|
|
57
|
+
"""Return request-local messages with image bytes; leave checkpoint messages unchanged."""
|
|
58
|
+
if not isinstance(input_, (list, tuple)):
|
|
59
|
+
return input_
|
|
60
|
+
messages: list[Any] = []
|
|
61
|
+
changed = False
|
|
62
|
+
for message in input_:
|
|
63
|
+
refs = refs_from_message(message) if isinstance(message, HumanMessage) else ()
|
|
64
|
+
if not refs:
|
|
65
|
+
messages.append(message)
|
|
66
|
+
continue
|
|
67
|
+
if store is None:
|
|
68
|
+
raise RuntimeError("Image attachment storage is not configured for this model")
|
|
69
|
+
blocks: list[dict[str, Any]] = []
|
|
70
|
+
content = message.content
|
|
71
|
+
if isinstance(content, str):
|
|
72
|
+
if content:
|
|
73
|
+
blocks.append(create_text_block(content))
|
|
74
|
+
elif isinstance(content, list):
|
|
75
|
+
blocks.extend(content)
|
|
76
|
+
for ref in refs:
|
|
77
|
+
attachment = store.read(ref)
|
|
78
|
+
blocks.append(create_image_block(
|
|
79
|
+
base64=base64.b64encode(attachment.data).decode("ascii"),
|
|
80
|
+
mime_type=attachment.mime_type,
|
|
81
|
+
))
|
|
82
|
+
additional = dict(message.additional_kwargs)
|
|
83
|
+
additional.pop(ATTACHMENT_META_KEY, None)
|
|
84
|
+
messages.append(message.model_copy(update={
|
|
85
|
+
"content": blocks,
|
|
86
|
+
"additional_kwargs": additional,
|
|
87
|
+
}))
|
|
88
|
+
changed = True
|
|
89
|
+
return messages if changed else input_
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class QwenChatOpenAI(ChatOpenAI):
|
|
93
|
+
"""Qwen Responses adapter with ChatOpenAI-compatible fallback routing."""
|
|
94
|
+
|
|
95
|
+
_attachment_store: AttachmentStore | None = PrivateAttr(default=None)
|
|
96
|
+
materializes_attachment_refs: ClassVar[bool] = True
|
|
97
|
+
|
|
98
|
+
@model_validator(mode="before")
|
|
99
|
+
@classmethod
|
|
100
|
+
def _use_proxy_free_clients(cls, values: Any) -> Any:
|
|
101
|
+
"""Keep direct construction safe from inherited WSL SOCKS settings too."""
|
|
102
|
+
if not isinstance(values, dict):
|
|
103
|
+
return values
|
|
104
|
+
if values.get("http_client") is None or values.get("http_async_client") is None:
|
|
105
|
+
import httpx
|
|
106
|
+
|
|
107
|
+
values = dict(values)
|
|
108
|
+
values.setdefault("http_socket_options", ())
|
|
109
|
+
if values.get("http_client") is None:
|
|
110
|
+
values["http_client"] = httpx.Client(trust_env=False)
|
|
111
|
+
if values.get("http_async_client") is None:
|
|
112
|
+
values["http_async_client"] = httpx.AsyncClient(trust_env=False)
|
|
113
|
+
return values
|
|
114
|
+
|
|
115
|
+
def set_attachment_store(self, store: AttachmentStore) -> None:
|
|
116
|
+
self._attachment_store = store
|
|
117
|
+
|
|
118
|
+
def _stream(self, *args: Any, **kwargs: Any) -> Iterator[ChatGenerationChunk]:
|
|
119
|
+
# ChatOpenAI routes directly to BaseChatOpenAI._stream_responses.
|
|
120
|
+
if self._use_responses_api({**kwargs, **self.model_kwargs}):
|
|
121
|
+
yield from self._stream_responses(*args, **kwargs)
|
|
122
|
+
else:
|
|
123
|
+
yield from super()._stream(*args, **kwargs)
|
|
124
|
+
|
|
125
|
+
async def _astream(self, *args: Any, **kwargs: Any) -> AsyncIterator[ChatGenerationChunk]:
|
|
126
|
+
# ChatOpenAI._astream directly calls BaseChatOpenAI._astream_responses,
|
|
127
|
+
# so it would bypass an override of _astream_responses on this class.
|
|
128
|
+
if self._use_responses_api({**kwargs, **self.model_kwargs}):
|
|
129
|
+
async for chunk in self._astream_responses(*args, **kwargs):
|
|
130
|
+
yield chunk
|
|
131
|
+
else:
|
|
132
|
+
async for chunk in super()._astream(*args, **kwargs):
|
|
133
|
+
yield chunk
|
|
134
|
+
|
|
135
|
+
def _get_request_payload(
|
|
136
|
+
self,
|
|
137
|
+
input_: Any,
|
|
138
|
+
*,
|
|
139
|
+
stop: list[str] | None = None,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> dict[str, Any]:
|
|
142
|
+
"""Keep the Gateway's documented nested ``extra_body`` on the wire.
|
|
143
|
+
|
|
144
|
+
OpenAI SDK treats an outer ``extra_body`` argument as transport options
|
|
145
|
+
and merges it into JSON. This extra envelope is removed once by that
|
|
146
|
+
SDK, producing Gateway's required ``{"extra_body": {...}}`` body.
|
|
147
|
+
"""
|
|
148
|
+
materialized = self._materialize_attachments(input_)
|
|
149
|
+
payload = super()._get_request_payload(materialized, stop=stop, **kwargs)
|
|
150
|
+
if not self._use_responses_api({**kwargs, **self.model_kwargs}):
|
|
151
|
+
return payload
|
|
152
|
+
extra_body = payload.get("extra_body")
|
|
153
|
+
if isinstance(extra_body, dict) and set(extra_body) != {"extra_body"}:
|
|
154
|
+
payload["extra_body"] = {"extra_body": extra_body}
|
|
155
|
+
return payload
|
|
156
|
+
|
|
157
|
+
def _materialize_attachments(self, input_: Any) -> Any:
|
|
158
|
+
return materialize_attachment_refs(input_, self._attachment_store)
|
|
159
|
+
|
|
160
|
+
def _stream_responses(
|
|
161
|
+
self,
|
|
162
|
+
messages: list[BaseMessage],
|
|
163
|
+
stop: list[str] | None = None,
|
|
164
|
+
run_manager: Any = None,
|
|
165
|
+
**kwargs: Any,
|
|
166
|
+
) -> Iterator[ChatGenerationChunk]:
|
|
167
|
+
"""LangChain's sync Responses loop with Qwen event normalization."""
|
|
168
|
+
self._ensure_sync_client_available()
|
|
169
|
+
kwargs["stream"] = True
|
|
170
|
+
payload = self._get_request_payload(messages, stop=stop, **kwargs)
|
|
171
|
+
headers: dict[str, Any] = {}
|
|
172
|
+
base_generation_info: dict[str, Any] = {}
|
|
173
|
+
try:
|
|
174
|
+
if self.include_response_headers or self._uses_gateway:
|
|
175
|
+
raw_context_manager = self.root_client.with_raw_response.responses.create(**payload)
|
|
176
|
+
context_manager = raw_context_manager.parse()
|
|
177
|
+
if self.include_response_headers:
|
|
178
|
+
headers = {"headers": dict(raw_context_manager.headers)}
|
|
179
|
+
_lc_base._add_gateway_metadata(base_generation_info, raw_context_manager)
|
|
180
|
+
else:
|
|
181
|
+
context_manager = self.root_client.responses.create(**payload)
|
|
182
|
+
|
|
183
|
+
original_schema_obj = kwargs.get("response_format")
|
|
184
|
+
with context_manager as response:
|
|
185
|
+
is_first_chunk = True
|
|
186
|
+
current_index = current_output_index = current_sub_index = -1
|
|
187
|
+
has_reasoning = False
|
|
188
|
+
for raw_chunk in response:
|
|
189
|
+
chunk = normalize_qwen_responses_event(raw_chunk)
|
|
190
|
+
metadata = headers if is_first_chunk else {}
|
|
191
|
+
current_index, current_output_index, current_sub_index, generation_chunk = (
|
|
192
|
+
_lc_base._convert_responses_chunk_to_generation_chunk(
|
|
193
|
+
chunk, current_index, current_output_index, current_sub_index,
|
|
194
|
+
schema=original_schema_obj, metadata=metadata,
|
|
195
|
+
has_reasoning=has_reasoning, output_version=self.output_version,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
if generation_chunk:
|
|
199
|
+
if is_first_chunk and base_generation_info:
|
|
200
|
+
generation_chunk.generation_info = {
|
|
201
|
+
**base_generation_info,
|
|
202
|
+
**(generation_chunk.generation_info or {}),
|
|
203
|
+
}
|
|
204
|
+
if run_manager:
|
|
205
|
+
run_manager.on_llm_new_token(generation_chunk.text, chunk=generation_chunk)
|
|
206
|
+
is_first_chunk = False
|
|
207
|
+
if "reasoning" in generation_chunk.message.additional_kwargs:
|
|
208
|
+
has_reasoning = True
|
|
209
|
+
yield generation_chunk
|
|
210
|
+
except _lc_base.openai.BadRequestError as error:
|
|
211
|
+
_lc_base._handle_openai_bad_request(error)
|
|
212
|
+
except _lc_base.openai.APIError as error:
|
|
213
|
+
_lc_base._handle_openai_api_error(error)
|
|
214
|
+
|
|
215
|
+
async def _astream_responses(
|
|
216
|
+
self,
|
|
217
|
+
messages: list[BaseMessage],
|
|
218
|
+
stop: list[str] | None = None,
|
|
219
|
+
run_manager: Any = None,
|
|
220
|
+
**kwargs: Any,
|
|
221
|
+
) -> AsyncIterator[ChatGenerationChunk]:
|
|
222
|
+
"""LangChain's Responses loop with one normalization before conversion."""
|
|
223
|
+
kwargs["stream"] = True
|
|
224
|
+
payload = self._get_request_payload(messages, stop=stop, **kwargs)
|
|
225
|
+
headers: dict[str, Any] = {}
|
|
226
|
+
base_generation_info: dict[str, Any] = {}
|
|
227
|
+
try:
|
|
228
|
+
if self.include_response_headers or self._uses_gateway:
|
|
229
|
+
raw_context_manager = await self.root_async_client.with_raw_response.responses.create(**payload)
|
|
230
|
+
context_manager = raw_context_manager.parse()
|
|
231
|
+
if self.include_response_headers:
|
|
232
|
+
headers = {"headers": dict(raw_context_manager.headers)}
|
|
233
|
+
_lc_base._add_gateway_metadata(base_generation_info, raw_context_manager)
|
|
234
|
+
else:
|
|
235
|
+
context_manager = await self.root_async_client.responses.create(**payload)
|
|
236
|
+
|
|
237
|
+
original_schema_obj = kwargs.get("response_format")
|
|
238
|
+
async with context_manager as response:
|
|
239
|
+
is_first_chunk = True
|
|
240
|
+
current_index = current_output_index = current_sub_index = -1
|
|
241
|
+
has_reasoning = False
|
|
242
|
+
async for raw_chunk in _lc_base._astream_with_chunk_timeout(
|
|
243
|
+
response, self.stream_chunk_timeout, model_name=self.model_name
|
|
244
|
+
):
|
|
245
|
+
chunk = normalize_qwen_responses_event(raw_chunk)
|
|
246
|
+
metadata = headers if is_first_chunk else {}
|
|
247
|
+
current_index, current_output_index, current_sub_index, generation_chunk = (
|
|
248
|
+
_lc_base._convert_responses_chunk_to_generation_chunk(
|
|
249
|
+
chunk, current_index, current_output_index, current_sub_index,
|
|
250
|
+
schema=original_schema_obj, metadata=metadata,
|
|
251
|
+
has_reasoning=has_reasoning, output_version=self.output_version,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
if generation_chunk:
|
|
255
|
+
if is_first_chunk and base_generation_info:
|
|
256
|
+
generation_chunk.generation_info = {
|
|
257
|
+
**base_generation_info,
|
|
258
|
+
**(generation_chunk.generation_info or {}),
|
|
259
|
+
}
|
|
260
|
+
if run_manager:
|
|
261
|
+
await run_manager.on_llm_new_token(generation_chunk.text, chunk=generation_chunk)
|
|
262
|
+
is_first_chunk = False
|
|
263
|
+
if "reasoning" in generation_chunk.message.additional_kwargs:
|
|
264
|
+
has_reasoning = True
|
|
265
|
+
yield generation_chunk
|
|
266
|
+
except _lc_base.openai.BadRequestError as error:
|
|
267
|
+
_lc_base._handle_openai_bad_request(error)
|
|
268
|
+
except _lc_base.openai.APIError as error:
|
|
269
|
+
_lc_base._handle_openai_api_error(error)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def chat_openai(
|
|
273
|
+
*,
|
|
274
|
+
model: str,
|
|
275
|
+
api_key: str,
|
|
276
|
+
base_url: str,
|
|
277
|
+
streaming: bool = True,
|
|
278
|
+
attachment_store: AttachmentStore | None = None,
|
|
279
|
+
) -> QwenChatOpenAI:
|
|
280
|
+
# Ignore ALL_PROXY/HTTP_PROXY from the shell (common WSL/SOCKS setups break
|
|
281
|
+
# localhost gateways and require optional httpx[socks]).
|
|
282
|
+
import httpx
|
|
283
|
+
|
|
284
|
+
client = QwenChatOpenAI(
|
|
285
|
+
model=model,
|
|
286
|
+
api_key=api_key,
|
|
287
|
+
base_url=base_url,
|
|
288
|
+
streaming=streaming,
|
|
289
|
+
max_retries=2,
|
|
290
|
+
use_responses_api=True,
|
|
291
|
+
output_version="responses/v1",
|
|
292
|
+
reasoning={"effort": "low"},
|
|
293
|
+
extra_body={"enable_thinking": True},
|
|
294
|
+
http_socket_options=(),
|
|
295
|
+
http_client=httpx.Client(trust_env=False),
|
|
296
|
+
http_async_client=httpx.AsyncClient(trust_env=False),
|
|
297
|
+
)
|
|
298
|
+
if attachment_store is not None:
|
|
299
|
+
client.set_attachment_store(attachment_store)
|
|
300
|
+
return client
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_chat_model(
|
|
304
|
+
profile: ModelProfile,
|
|
305
|
+
*,
|
|
306
|
+
streaming: bool = True,
|
|
307
|
+
attachment_store: AttachmentStore | None = None,
|
|
308
|
+
) -> ChatOpenAI:
|
|
309
|
+
"""Build the template chat client from a ModelProfile."""
|
|
310
|
+
if profile.provider == "qwen-responses":
|
|
311
|
+
model = chat_openai(
|
|
312
|
+
model=profile.model,
|
|
313
|
+
api_key=profile.api_key,
|
|
314
|
+
base_url=profile.base_url,
|
|
315
|
+
streaming=streaming,
|
|
316
|
+
attachment_store=attachment_store,
|
|
317
|
+
)
|
|
318
|
+
elif profile.provider == "openai-compatible":
|
|
319
|
+
import httpx
|
|
320
|
+
|
|
321
|
+
model = ChatOpenAI(
|
|
322
|
+
model=profile.model,
|
|
323
|
+
api_key=profile.api_key,
|
|
324
|
+
base_url=profile.base_url,
|
|
325
|
+
streaming=streaming,
|
|
326
|
+
stream_usage=profile.stream_usage,
|
|
327
|
+
max_retries=2,
|
|
328
|
+
use_responses_api=False,
|
|
329
|
+
http_socket_options=(),
|
|
330
|
+
http_client=httpx.Client(trust_env=False),
|
|
331
|
+
http_async_client=httpx.AsyncClient(trust_env=False),
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
raise ValueError(f"Unsupported model provider: {profile.provider}")
|
|
335
|
+
if profile.context_window > 0:
|
|
336
|
+
# deepagents reads this model profile to choose its 85% compaction
|
|
337
|
+
# threshold and to check the request's input budget.
|
|
338
|
+
model.profile = {**(model.profile or {}), "max_input_tokens": profile.context_window}
|
|
339
|
+
return model
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from agent.middleware.cancel_tools import ToolCancelMiddleware
|
|
2
|
+
from agent.middleware.pause import PauseGateMiddleware
|
|
3
|
+
from agent.middleware.steering import SteeringMiddleware
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"PauseGateMiddleware",
|
|
7
|
+
"SteeringMiddleware",
|
|
8
|
+
"ToolCancelMiddleware",
|
|
9
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Resolve image references only in model requests, leaving checkpoints compact."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextvars import ContextVar, Token
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
8
|
+
from agent.attachments import AttachmentStore
|
|
9
|
+
from agent.llm import QwenChatOpenAI, materialize_attachment_refs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_active_store: ContextVar[AttachmentStore | None] = ContextVar("attachment_store", default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_attachment_store(store: AttachmentStore) -> Token[AttachmentStore | None]:
|
|
16
|
+
return _active_store.set(store)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def reset_attachment_store(token: Token[AttachmentStore | None]) -> None:
|
|
20
|
+
_active_store.reset(token)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AttachmentMaterializationMiddleware(AgentMiddleware):
|
|
24
|
+
def wrap_model_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
25
|
+
if isinstance(request.model, QwenChatOpenAI):
|
|
26
|
+
return handler(request)
|
|
27
|
+
store = _active_store.get()
|
|
28
|
+
if store is None:
|
|
29
|
+
return handler(request)
|
|
30
|
+
messages = materialize_attachment_refs(request.messages, store)
|
|
31
|
+
return handler(request.override(messages=messages)) if messages is not request.messages else handler(request)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Establish per-tool cancel contexts and optional cancellable tool hooks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
7
|
+
from langchain_core.messages import ToolMessage
|
|
8
|
+
|
|
9
|
+
from agent.control import RunController
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ToolCancelMiddleware(AgentMiddleware):
|
|
13
|
+
"""Wrap each tool call with a cancel context bound to the active RunController."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, controller: RunController) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._controller = controller
|
|
18
|
+
|
|
19
|
+
def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
20
|
+
tool_call = getattr(request, "tool_call", None) or {}
|
|
21
|
+
tool_name = str(tool_call.get("name") or getattr(getattr(request, "tool", None), "name", "") or "tool")
|
|
22
|
+
tool_call_id = str(tool_call.get("id") or "")
|
|
23
|
+
ctx = self._controller.open_tool_context(tool_name=tool_name, tool_call_id=tool_call_id)
|
|
24
|
+
tool = getattr(request, "tool", None)
|
|
25
|
+
if tool is not None:
|
|
26
|
+
cancel = getattr(tool, "cancel", None)
|
|
27
|
+
if callable(cancel):
|
|
28
|
+
ctx.register_callback(cancel)
|
|
29
|
+
try:
|
|
30
|
+
if ctx.cancelled:
|
|
31
|
+
return ToolMessage(
|
|
32
|
+
content="Cancelled by user.",
|
|
33
|
+
tool_call_id=tool_call_id or tool_name,
|
|
34
|
+
name=tool_name,
|
|
35
|
+
status="error",
|
|
36
|
+
)
|
|
37
|
+
return handler(request)
|
|
38
|
+
finally:
|
|
39
|
+
self._controller.close_tool_context(ctx)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Pause at the next model-call safe point via LangGraph interrupt().
|
|
2
|
+
# Do not build a custom pause engine; the checkpointer stores state and resume uses Command(resume=...).
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
7
|
+
from langgraph.types import interrupt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PauseGateMiddleware(AgentMiddleware):
|
|
11
|
+
def __init__(self, should_pause: Callable[[], bool]) -> None:
|
|
12
|
+
super().__init__()
|
|
13
|
+
self._should_pause = should_pause
|
|
14
|
+
|
|
15
|
+
def wrap_model_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
16
|
+
if self._should_pause():
|
|
17
|
+
interrupt({"type": "pause"})
|
|
18
|
+
return handler(request)
|