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/session_runtime.py
ADDED
|
@@ -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
|
agent/tools/__init__.py
ADDED
|
@@ -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
|
+
]
|
agent/tools/examples.py
ADDED
|
@@ -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
|
+
)
|