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
@@ -0,0 +1,209 @@
1
+ """Session ownership: which persisted thread this runner may write, and its lease.
2
+
3
+ The runtime owns the thread lease, the switch bookkeeping and the session
4
+ catalog metadata. It never touches the graph — loading snapshots and rebuilding
5
+ models stay with AgentRunner, which coordinates through these primitives.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from threading import Event
11
+ from typing import Any, Callable
12
+ from uuid import uuid4
13
+
14
+ from agent.config import Settings
15
+ from agent.middleware.recovery import RecoveryContext
16
+ from agent.permission import (
17
+ PermissionMode,
18
+ allow_mode_available,
19
+ parse_permission_mode,
20
+ )
21
+ from agent.session import SessionInfo, SessionStore, StopReason
22
+ from agent.session_lock import (
23
+ InProcessSessionLease,
24
+ InProcessSessionLockManager,
25
+ SessionLease,
26
+ SessionLockBusyError,
27
+ )
28
+
29
+ SessionLeaseLike = SessionLease | InProcessSessionLease
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class RestorePlan:
34
+ """Model and permission a target thread should run with after a switch."""
35
+
36
+ model_id: str
37
+ permission_mode: PermissionMode
38
+ notices: tuple[str, ...] = ()
39
+
40
+
41
+ class SessionRuntime:
42
+ """Lease lifecycle plus catalog metadata for one runner's session."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ session_store: SessionStore | None,
48
+ checkpointer: Any | None,
49
+ settings: Settings | None,
50
+ ) -> None:
51
+ self._store = session_store
52
+ self._settings = settings
53
+ self._in_process_locks = (
54
+ None if session_store is not None else InProcessSessionLockManager(checkpointer)
55
+ )
56
+ self._thread_id: str | None = None
57
+ self._lease: SessionLeaseLike | None = None
58
+ self._pending_switch: str | None = None
59
+ self._abort_wait = Event()
60
+
61
+ @property
62
+ def thread_id(self) -> str | None:
63
+ return self._thread_id
64
+
65
+ @property
66
+ def lease(self) -> SessionLeaseLike | None:
67
+ return self._lease
68
+
69
+ @property
70
+ def pending_switch(self) -> str | None:
71
+ return self._pending_switch
72
+
73
+ def owns(self, thread_id: str) -> bool:
74
+ return self._lease is not None and self._lease.thread_id == thread_id
75
+
76
+ def acquire_initial(
77
+ self, thread_id: str | None, *, model_id: str, permission_mode: str,
78
+ ) -> str:
79
+ """Resolve or create the first thread, then take its lease."""
80
+ if self._store is None:
81
+ target = thread_id or f"cli-{uuid4()}"
82
+ elif thread_id is None:
83
+ info = self._store.create_session(model_id=model_id, permission_mode=permission_mode)
84
+ target = info.id
85
+ else:
86
+ if self._store.get(thread_id) is None:
87
+ self._store.create_session(
88
+ session_id=thread_id, model_id=model_id, permission_mode=permission_mode,
89
+ )
90
+ target = thread_id
91
+ lease = self.try_acquire(target)
92
+ if lease is None:
93
+ raise SessionLockBusyError(f"Session {target} is already open in another window")
94
+ self._thread_id = target
95
+ self._lease = lease
96
+ return target
97
+
98
+ def try_acquire(self, thread_id: str) -> SessionLeaseLike | None:
99
+ if self._store is not None:
100
+ return self._store.try_acquire_session(thread_id)
101
+ assert self._in_process_locks is not None
102
+ return self._in_process_locks.try_acquire(thread_id)
103
+
104
+ def wait_acquire(
105
+ self, thread_id: str, *, cancelled: Callable[[], bool] | None = None,
106
+ ) -> SessionLeaseLike | None:
107
+ def combined() -> bool:
108
+ return self._abort_wait.is_set() or (cancelled is not None and cancelled())
109
+
110
+ if self._store is not None:
111
+ return self._store.wait_acquire_session(thread_id, cancelled=combined)
112
+ assert self._in_process_locks is not None
113
+ return self._in_process_locks.wait_acquire(thread_id, cancelled=combined)
114
+
115
+ def adopt(self, thread_id: str, lease: SessionLeaseLike) -> None:
116
+ """Bind a freshly acquired target and drop the previous lease."""
117
+ previous = self._lease
118
+ self._thread_id = thread_id
119
+ self._lease = lease
120
+ self._pending_switch = None
121
+ if previous is not None:
122
+ previous.release()
123
+
124
+ def adopt_new(self, thread_id: str) -> None:
125
+ """Take over a thread this runner just created."""
126
+ lease = self.try_acquire(thread_id)
127
+ if lease is None:
128
+ raise SessionLockBusyError(f"Session {thread_id} is already open in another window")
129
+ self.adopt(thread_id, lease)
130
+
131
+ def bind(self, thread_id: str) -> None:
132
+ """Point at a thread whose lease is already held (A→A reload)."""
133
+ self._thread_id = thread_id
134
+ self._pending_switch = None
135
+
136
+ def detach_for_wait(self, target_id: str) -> None:
137
+ """Release the current lease and refuse operations while waiting."""
138
+ lease = self._lease
139
+ self._lease = None
140
+ if lease is not None:
141
+ lease.release()
142
+ self._pending_switch = target_id
143
+
144
+ def clear_pending(self) -> None:
145
+ self._pending_switch = None
146
+
147
+ def release(self) -> None:
148
+ lease = self._lease
149
+ self._lease = None
150
+ if lease is not None:
151
+ lease.release()
152
+
153
+ def abort_wait(self) -> None:
154
+ self._abort_wait.set()
155
+
156
+ def close(self) -> None:
157
+ self._abort_wait.set()
158
+ self.release()
159
+
160
+ def list_sessions(self, *, limit: int = 50) -> list[SessionInfo]:
161
+ if self._store is None:
162
+ return []
163
+ return self._store.list_sessions(limit=limit)
164
+
165
+ def resolve(self, session_id: str) -> SessionInfo | None:
166
+ if self._store is None:
167
+ return None
168
+ return self._store.resolve_prefix(session_id) or self._store.get(session_id)
169
+
170
+ def get(self, session_id: str) -> SessionInfo | None:
171
+ if self._store is None:
172
+ return None
173
+ return self._store.get(session_id)
174
+
175
+ def thread_has_checkpoint(self, thread_id: str) -> bool:
176
+ if self._store is None:
177
+ return False
178
+ return self._store.checkpointer.get_tuple(
179
+ {"configurable": {"thread_id": thread_id}}
180
+ ) is not None
181
+
182
+ def restore_plan(self, info: SessionInfo, *, execution_mode: Any) -> RestorePlan:
183
+ """Decide which model and permission the target thread should run with."""
184
+ notices: list[str] = []
185
+ profile = self._settings.active_profile if self._settings is not None else None
186
+ if self._settings is not None and info.model_id:
187
+ try:
188
+ profile = self._settings.get_profile(info.model_id)
189
+ except KeyError:
190
+ notices.append(f"Saved model {info.model_id} is unavailable; using {profile.id}.")
191
+ mode = parse_permission_mode(info.permission_mode or "ask") or PermissionMode.ASK
192
+ if mode is PermissionMode.ALLOW and not allow_mode_available(execution_mode):
193
+ mode = PermissionMode.ASK
194
+ notices.append("Saved allow permission is unavailable here; using ask.")
195
+ return RestorePlan(
196
+ model_id=profile.id if profile is not None else "",
197
+ permission_mode=mode,
198
+ notices=tuple(notices),
199
+ )
200
+
201
+ def recovery_context(
202
+ self, info: SessionInfo, *, has_checkpoint: bool, interrupt_active: bool,
203
+ ) -> RecoveryContext | None:
204
+ needs_recovery = info.last_run_status in {StopReason.ABORTED, StopReason.ERROR} or (
205
+ info.last_run_status is StopReason.PENDING and has_checkpoint
206
+ )
207
+ if needs_recovery and not interrupt_active:
208
+ return RecoveryContext.for_stop_reason(info.last_run_status)
209
+ return None
agent/stream.py ADDED
@@ -0,0 +1,168 @@
1
+ # Token-level streaming for reasoning / assistant via LangChain callbacks.
2
+ # ChatOpenAI(..., streaming=True) emits AIMessageChunk; this splits think vs visible text.
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from langchain_core.callbacks import BaseCallbackHandler
9
+ from langchain_core.messages import AIMessageChunk, BaseMessage
10
+
11
+ DeltaHandler = Callable[[str, str], None]
12
+
13
+
14
+ def message_text(message: BaseMessage) -> str:
15
+ text = getattr(message, "text", None)
16
+ if isinstance(text, str) and text:
17
+ return text
18
+ content = message.content
19
+ if isinstance(content, str):
20
+ return content
21
+ parts: list[str] = []
22
+ for block in content if isinstance(content, list) else []:
23
+ if isinstance(block, dict) and block.get("type") == "text":
24
+ parts.append(str(block.get("text", "")))
25
+ elif isinstance(block, str):
26
+ parts.append(block)
27
+ return "".join(parts)
28
+
29
+
30
+ def _split_think(text: str) -> tuple[str, str]:
31
+ """Split complete or streaming think tags without exposing partial tags."""
32
+ visible: list[str] = []
33
+ reasoning: list[str] = []
34
+ lower = text.lower()
35
+ cursor = 0
36
+ while (start := lower.find("<think>", cursor)) >= 0:
37
+ visible.append(_without_partial_tag(text[cursor:start]))
38
+ end = lower.find("</think>", start + len("<think>"))
39
+ if end < 0:
40
+ reasoning.append(_without_partial_tag(text[start + len("<think>"):]))
41
+ return "".join(visible), "".join(reasoning)
42
+ reasoning.append(text[start + len("<think>"):end])
43
+ cursor = end + len("</think>")
44
+ visible.append(_without_partial_tag(text[cursor:]))
45
+ return "".join(visible), "".join(reasoning)
46
+
47
+
48
+ def _without_partial_tag(text: str) -> str:
49
+ marker = text.rfind("<")
50
+ if marker >= 0 and any(
51
+ tag.startswith(text[marker:].lower()) for tag in ("<think>", "</think>")
52
+ ):
53
+ return text[:marker]
54
+ return text
55
+
56
+
57
+ def reasoning_text(message: BaseMessage) -> str:
58
+ extra = getattr(message, "additional_kwargs", None) or {}
59
+ for key in ("reasoning_content", "reasoning", "thinking", "reasoning_summary"):
60
+ value = extra.get(key)
61
+ if isinstance(value, str) and value:
62
+ return value
63
+ meta = getattr(message, "response_metadata", None) or {}
64
+ for key in ("reasoning_content", "reasoning", "thinking"):
65
+ value = meta.get(key)
66
+ if isinstance(value, str) and value:
67
+ return value
68
+ content = message.content
69
+ parts: list[str] = []
70
+ for block in content if isinstance(content, list) else []:
71
+ if not isinstance(block, dict):
72
+ continue
73
+ if str(block.get("type") or "") not in {"reasoning", "thinking"}:
74
+ continue
75
+ summary = block.get("summary")
76
+ if isinstance(summary, list):
77
+ for item in summary:
78
+ if isinstance(item, dict):
79
+ parts.append(str(item.get("text") or ""))
80
+ elif item:
81
+ parts.append(str(item))
82
+ parts.append(str(block.get("text") or block.get("reasoning") or block.get("reasoning_content") or ""))
83
+ if parts:
84
+ return "".join(parts)
85
+ return _split_think(message_text(message))[1]
86
+
87
+
88
+ def visible_text(message: BaseMessage) -> str:
89
+ return _split_think(message_text(message))[0]
90
+
91
+
92
+ def _chunk_message(chunk: Any) -> BaseMessage | None:
93
+ if chunk is None:
94
+ return None
95
+ if isinstance(chunk, BaseMessage):
96
+ return chunk
97
+ message = getattr(chunk, "message", None)
98
+ return message if isinstance(message, BaseMessage) else None
99
+
100
+
101
+ class StreamDeltaCallback(BaseCallbackHandler):
102
+ """LangChain token callback → on_delta("reasoning"|"assistant", cumulative_text)."""
103
+
104
+ def __init__(
105
+ self,
106
+ on_delta: DeltaHandler,
107
+ *,
108
+ on_reasoning: Callable[[str], None] | None = None,
109
+ on_assistant: Callable[[str], None] | None = None,
110
+ on_start: Callable[[], None] | None = None,
111
+ on_end: Callable[[str, str], None] | None = None,
112
+ ) -> None:
113
+ super().__init__()
114
+ self._on_delta = on_delta
115
+ self._on_reasoning = on_reasoning
116
+ self._on_assistant = on_assistant
117
+ self._on_start = on_start
118
+ self._on_end = on_end
119
+ self._assistant = ""
120
+ self._reasoning = ""
121
+ self._message: AIMessageChunk | None = None
122
+ self._raw_text = ""
123
+
124
+ def on_llm_start(self, *args: Any, **kwargs: Any) -> None:
125
+ self._assistant = ""
126
+ self._reasoning = ""
127
+ self._message = None
128
+ self._raw_text = ""
129
+ if self._on_start:
130
+ self._on_start()
131
+
132
+ def on_llm_new_token(self, token: str, *, chunk: Any = None, **kwargs: Any) -> None:
133
+ message = _chunk_message(chunk)
134
+ if isinstance(message, AIMessageChunk):
135
+ self._message = message if self._message is None else self._message + message
136
+ visible = visible_text(self._message)
137
+ reasoning = reasoning_text(self._message)
138
+ else:
139
+ self._raw_text += token
140
+ visible, reasoning = _split_think(self._raw_text)
141
+ if reasoning != self._reasoning:
142
+ self._reasoning = reasoning
143
+ self._on_delta("reasoning", reasoning)
144
+ if self._on_reasoning:
145
+ self._on_reasoning(reasoning)
146
+ if visible != self._assistant:
147
+ self._assistant = visible
148
+ self._on_delta("assistant", visible)
149
+ if self._on_assistant:
150
+ self._on_assistant(visible)
151
+
152
+ def on_llm_end(self, *args: Any, **kwargs: Any) -> None:
153
+ if self._on_end:
154
+ self._on_end(self._assistant, self._reasoning)
155
+ self._assistant = ""
156
+ self._reasoning = ""
157
+
158
+
159
+ def merge_stream_callbacks(config: dict[str, Any], callback: BaseCallbackHandler) -> dict[str, Any]:
160
+ merged = dict(config)
161
+ existing = merged.get("callbacks")
162
+ if isinstance(existing, list):
163
+ merged["callbacks"] = [*existing, callback]
164
+ elif existing:
165
+ merged["callbacks"] = [existing, callback]
166
+ else:
167
+ merged["callbacks"] = [callback]
168
+ return merged
@@ -0,0 +1,9 @@
1
+ from agent.tools.examples import build_example_tools
2
+ from agent.tools.execute import build_execute_tool
3
+ from agent.tools.human_input import build_human_input_tools
4
+
5
+ __all__ = [
6
+ "build_example_tools",
7
+ "build_execute_tool",
8
+ "build_human_input_tools",
9
+ ]
@@ -0,0 +1,30 @@
1
+ # Optional documentation lookup example. It is not part of the default agent.
2
+ from langchain_core.tools import BaseTool, StructuredTool
3
+
4
+ DOCS = {
5
+ "deep-agent": "Deep Agents 以 create_deep_agent() 装配 LangGraph 图,中间件负责文件系统、HITL、Skills 与重试。",
6
+ "middleware": "模板默认栈:PauseGate → ModelRetry → Filesystem。",
7
+ "hitl": "interrupt_on 标记的工具会在执行前暂停,等待 approve / reject。",
8
+ }
9
+
10
+
11
+ def lookup_docs(query: str) -> str:
12
+ """Search local documentation. ALLOW — runs immediately without confirmation."""
13
+ key = (query or "").strip().lower()
14
+ for name, text in DOCS.items():
15
+ if name in key or key in name:
16
+ return text
17
+ return "未找到文档。可检索:deep-agent、middleware、hitl。"
18
+
19
+
20
+ def build_example_tools() -> list[BaseTool]:
21
+ return [
22
+ StructuredTool.from_function(
23
+ func=lookup_docs,
24
+ name="lookup_docs",
25
+ description=(
26
+ "检索本地文档。参数 query 为关键词(deep-agent / middleware / hitl)。"
27
+ "该工具可直接执行,不需要人工确认。"
28
+ ),
29
+ ),
30
+ ]
agent/tools/execute.py ADDED
@@ -0,0 +1,73 @@
1
+ """Local execute tool with optional NETWORK capability declaration."""
2
+ from __future__ import annotations
3
+
4
+ from deepagents.backends.protocol import SandboxBackendProtocol
5
+ from langchain_core.tools import BaseTool, StructuredTool
6
+ from agent.network import reset_execute_network, set_execute_network
7
+
8
+ _ASK_DESCRIPTION = (
9
+ "Run a shell command inside the sandbox workspace (/workspace). "
10
+ "Defaults to no network. Under permission ask every execute needs "
11
+ "approval. network=true grants host network access, including localhost "
12
+ "and LAN addresses, for this command. "
13
+ "Prefer ls/read_file/glob/grep/write_file for filesystem work."
14
+ )
15
+ _ALLOW_DESCRIPTION = (
16
+ "Run a shell command inside the sandbox workspace (/workspace). "
17
+ "Permission allow mode: commands run without approval and host network "
18
+ "(internet, localhost, LAN) is enabled by default for every call. "
19
+ "Prefer ls/read_file/glob/grep/write_file for filesystem work."
20
+ )
21
+
22
+
23
+ def build_execute_tool(
24
+ backend: SandboxBackendProtocol,
25
+ *,
26
+ network_by_default: bool = False,
27
+ ) -> BaseTool:
28
+ """Shell execute bound to a sandbox backend.
29
+
30
+ ``network=True`` shares the host network namespace for this command.
31
+ ``network_by_default=True`` (permission mode allow) opens host network
32
+ for every call; a per-call ``network=false`` cannot turn it off.
33
+ """
34
+
35
+ def execute(
36
+ command: str,
37
+ timeout: int | None = None,
38
+ network: bool = False,
39
+ ) -> tuple[str, dict[str, object]]:
40
+ token = set_execute_network(network or network_by_default)
41
+ try:
42
+ if timeout is not None:
43
+ response = backend.execute(command, timeout=timeout)
44
+ else:
45
+ response = backend.execute(command)
46
+ finally:
47
+ reset_execute_network(token)
48
+ output = response.output or ""
49
+ if response.exit_code not in (0, None):
50
+ suffix = f"\n\nExit code: {response.exit_code}"
51
+ if suffix.strip() not in output:
52
+ output = f"{output.rstrip()}{suffix}"
53
+ if response.truncated:
54
+ marker = "\n\n[output truncated]"
55
+ if "[Output truncated: showing " not in output and marker not in output:
56
+ output = f"{output.rstrip()}{marker}"
57
+ artifact: dict[str, object] = {
58
+ "exit_code": response.exit_code,
59
+ "truncated": response.truncated,
60
+ "host_log_path": getattr(response, "host_log_path", None),
61
+ "agent_log_path": getattr(response, "agent_log_path", None),
62
+ "log_error": getattr(response, "log_error", None),
63
+ "termination_reason": getattr(response, "termination_reason", None),
64
+ "max_output_bytes": getattr(response, "max_output_bytes", None),
65
+ }
66
+ return output, artifact
67
+
68
+ return StructuredTool.from_function(
69
+ func=execute,
70
+ name="execute",
71
+ response_format="content_and_artifact",
72
+ description=_ALLOW_DESCRIPTION if network_by_default else _ASK_DESCRIPTION,
73
+ )
@@ -0,0 +1,170 @@
1
+ # request_human_input — local LangGraph interrupt tool.
2
+ # The tool body only builds a Semantic Interaction Schema and calls interrupt().
3
+ # Resume hands the human's values back to the agent. No side effects before interrupt().
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ from langchain_core.tools import BaseTool, StructuredTool
11
+ from langgraph.types import interrupt
12
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
13
+
14
+ from agent.tools.human_interaction import (
15
+ FIELD_TYPES,
16
+ INTERACTION_TYPES,
17
+ FieldType,
18
+ InteractionType,
19
+ normalize_interaction_request,
20
+ resume_values,
21
+ )
22
+
23
+
24
+ REQUEST_TOOL_NAME = "request_human_input"
25
+
26
+
27
+ class FieldOption(BaseModel):
28
+ """single_select / multi_select 的一条候选项。"""
29
+
30
+ model_config = ConfigDict(extra="ignore")
31
+
32
+ value: str = Field(
33
+ default="",
34
+ description="选项值,用户选中后按这个值回传",
35
+ validation_alias=AliasChoices("value", "id"),
36
+ )
37
+ label: str = Field(default="", description="展示给用户的选项文案")
38
+ description: str = Field(default="", description="选项补充说明,可留空")
39
+
40
+
41
+ class FieldSpec(BaseModel):
42
+ """一个待用户填写的字段。只声明交互语义,不指定前端组件。"""
43
+
44
+ model_config = ConfigDict(extra="ignore")
45
+
46
+ id: str = Field(default="", description="字段 id,用户的回答按这个 key 回传")
47
+ type: FieldType = Field(default="text", description="字段类型")
48
+ label: str = Field(default="", description="字段标题")
49
+ required: bool = Field(default=False, description="是否必须回答")
50
+ placeholder: str = Field(default="", description="输入框占位提示,可留空")
51
+ options: list[FieldOption] = Field(
52
+ default_factory=list,
53
+ description="single_select / multi_select 的候选项,其他类型留空数组",
54
+ )
55
+
56
+ @field_validator("type", mode="before")
57
+ @classmethod
58
+ def _lenient_type(cls, value: Any) -> str:
59
+ text = str(value or "").strip().lower()
60
+ return text if text in FIELD_TYPES else "text"
61
+
62
+
63
+ class Recommendation(BaseModel):
64
+ """推荐答案。value 对应某个 field 的选项值或建议填写的内容。"""
65
+
66
+ model_config = ConfigDict(extra="ignore")
67
+
68
+ value: str = Field(default="", description="推荐选中或填写的内容")
69
+ reason: str = Field(default="", description="推荐理由,可留空")
70
+
71
+
72
+ class HumanInputArgs(BaseModel):
73
+ """request_human_input 的参数。fields / recommendation / impact 必须是原生 JSON 结构。"""
74
+
75
+ model_config = ConfigDict(extra="ignore")
76
+
77
+ reason: str = Field(description="为什么必须由用户介入才能继续")
78
+ question: str = Field(description="简短题干,不要把选项写成 Markdown 列表")
79
+ interaction_type: InteractionType = Field(
80
+ default="clarification", description="交互类型"
81
+ )
82
+ title: str = Field(default="", description="交互标题,可留空")
83
+ fields: list[FieldSpec] = Field(
84
+ default_factory=list,
85
+ description="需要用户填写的字段;留空表示只有一个自由文本回复框",
86
+ )
87
+ recommendation: Recommendation | None = Field(
88
+ default=None, description="推荐答案,没有明确推荐时整个省略"
89
+ )
90
+ impact: list[str] = Field(
91
+ default_factory=list, description="这个决策会影响什么,每条一句;可留空"
92
+ )
93
+
94
+ @field_validator("interaction_type", mode="before")
95
+ @classmethod
96
+ def _lenient_interaction_type(cls, value: Any) -> str:
97
+ text = str(value or "").strip().lower()
98
+ return text if text in INTERACTION_TYPES else "clarification"
99
+
100
+ @field_validator("fields", "recommendation", "impact", mode="before")
101
+ @classmethod
102
+ def _decode_json_text(cls, value: Any) -> Any:
103
+ # 模型和网关有时会把嵌套参数整体 json.dumps 成字符串;解析失败时原样返回,
104
+ # 交给 pydantic 报错,再由 ToolArgHintMiddleware 转成可操作的提示。
105
+ return _decode_json_text(value)
106
+
107
+
108
+ def build_human_input_tools() -> list[BaseTool]:
109
+ return [_build_tool(REQUEST_TOOL_NAME, _request_description())]
110
+
111
+
112
+ def _build_tool(name: str, description: str) -> BaseTool:
113
+ def request_human_input(
114
+ reason: str,
115
+ question: str,
116
+ interaction_type: InteractionType = "clarification",
117
+ title: str = "",
118
+ fields: list[FieldSpec] | None = None,
119
+ recommendation: Recommendation | None = None,
120
+ impact: list[str] | None = None,
121
+ ) -> str:
122
+ payload = normalize_interaction_request(
123
+ reason=reason,
124
+ question=question,
125
+ interaction_type=interaction_type,
126
+ title=title,
127
+ fields=[_plain(field) for field in fields] if fields is not None else None,
128
+ recommendation=_plain(recommendation),
129
+ impact=list(impact) if impact is not None else None,
130
+ interaction_id=str(uuid4()),
131
+ )
132
+ answer = interrupt(payload)
133
+ values = resume_values(answer)
134
+ return json.dumps(values, ensure_ascii=False)
135
+
136
+ return StructuredTool.from_function(
137
+ func=request_human_input,
138
+ name=name,
139
+ description=description,
140
+ args_schema=HumanInputArgs,
141
+ )
142
+
143
+
144
+ def _decode_json_text(value: Any) -> Any:
145
+ if not isinstance(value, str):
146
+ return value
147
+ text = value.strip()
148
+ if not text or text[0] not in "[{":
149
+ return value
150
+ try:
151
+ return json.loads(text)
152
+ except json.JSONDecodeError:
153
+ return value
154
+
155
+
156
+ def _plain(value: Any) -> Any:
157
+ return value.model_dump() if isinstance(value, BaseModel) else value
158
+
159
+
160
+ def _request_description() -> str:
161
+ return (
162
+ "请求用户提供继续执行所需的信息或决策,并暂停直到收到响应。"
163
+ "当你要让用户从多个后续操作中选择时,必须调用本工具,不要只在普通回复中列出编号选项。"
164
+ "只声明交互语义,不要指定前端组件。"
165
+ "fields / recommendation / impact 必须传原生 JSON 结构,不要序列化成字符串。"
166
+ "有可选项时必须用 single_select 或 multi_select,选项写进该字段的 options,"
167
+ "不要把选项写成 question 里的 Markdown 列表。"
168
+ "question 只保留简短题干。"
169
+ "recommendation 与 impact 可选。这不是工具权限确认。"
170
+ )