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.
Files changed (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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
@@ -0,0 +1,5 @@
1
+ """Shared file mutation operation vocabulary."""
2
+ from typing import Literal
3
+
4
+ FileMutationOperation = Literal["create", "modify", "overwrite", "delete"]
5
+ WriteOperation = Literal["create", "overwrite"]
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)