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,65 @@
1
+ """One-shot recovery context for model requests, never checkpoint messages."""
2
+ from __future__ import annotations
3
+
4
+ from contextvars import ContextVar, Token
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable
7
+
8
+ from langchain.agents.middleware import AgentMiddleware
9
+ from langchain_core.messages import SystemMessage
10
+
11
+ from agent.session import StopReason
12
+
13
+
14
+ _active_context: ContextVar[RecoveryContext | None] = ContextVar("recovery_context", default=None)
15
+
16
+
17
+ @dataclass
18
+ class RecoveryContext:
19
+ text: str | None
20
+ armed: bool = False
21
+
22
+ @classmethod
23
+ def for_stop_reason(cls, reason: StopReason) -> "RecoveryContext | None":
24
+ descriptions = {
25
+ StopReason.PENDING: "The previous run did not record a normal end and may have exited unexpectedly.",
26
+ StopReason.ABORTED: "The previous run was aborted by the user.",
27
+ StopReason.ERROR: "The previous run ended with an execution error.",
28
+ }
29
+ description = descriptions.get(reason)
30
+ if description is None:
31
+ return None
32
+ return cls(
33
+ text="[Previous run status]\n"
34
+ f"{description} Some side effects may have partially completed. "
35
+ "Check the checkpoint and current workspace state before continuing. "
36
+ "Do not assume unfinished operations succeeded or failed. "
37
+ "Follow the user's current instruction."
38
+ )
39
+
40
+
41
+ def set_recovery_context(context: RecoveryContext | None) -> Token[RecoveryContext | None]:
42
+ return _active_context.set(context)
43
+
44
+
45
+ def reset_recovery_context(token: Token[RecoveryContext | None]) -> None:
46
+ _active_context.reset(token)
47
+
48
+
49
+ class RecoveryContextMiddleware(AgentMiddleware):
50
+ def wrap_model_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
51
+ context = _active_context.get()
52
+ if context is None or not context.armed or context.text is None:
53
+ return handler(request)
54
+ original = request.system_message
55
+ if original is None:
56
+ system_message = SystemMessage(content=context.text)
57
+ elif isinstance(original.content, str):
58
+ system_message = original.model_copy(update={"content": f"{original.content}\n\n{context.text}"})
59
+ else:
60
+ system_message = original.model_copy(update={
61
+ "content": [*original.content, {"type": "text", "text": context.text}],
62
+ })
63
+ response = handler(request.override(system_message=system_message))
64
+ context.text = None
65
+ return response
@@ -0,0 +1,35 @@
1
+ """Inject mid-turn steering HumanMessages at model-safe boundaries."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Callable
5
+
6
+ from langchain.agents.middleware import AgentMiddleware
7
+ from langchain.agents.middleware.types import hook_config
8
+ from langchain_core.messages import HumanMessage
9
+
10
+ from agent.control import RunController
11
+
12
+
13
+ class SteeringMiddleware(AgentMiddleware):
14
+ """Consume one steering message per before_model / after_agent boundary."""
15
+
16
+ def __init__(self, controller: RunController) -> None:
17
+ super().__init__()
18
+ self._controller = controller
19
+
20
+ def before_model(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ANN401
21
+ return self._consume(state)
22
+
23
+ @hook_config(can_jump_to=["model"])
24
+ def after_agent(self, state: Any, runtime: Any) -> dict[str, Any] | None: # noqa: ANN401
25
+ update = self._consume(state)
26
+ if update is None:
27
+ return None
28
+ update["jump_to"] = "model"
29
+ return update
30
+
31
+ def _consume(self, state: Any) -> dict[str, Any] | None: # noqa: ANN401
32
+ text = self._controller.pop_steering()
33
+ if text is None:
34
+ return None
35
+ return {"messages": [HumanMessage(content=text)]}
@@ -0,0 +1,128 @@
1
+ """Turn tool argument validation failures into guidance the model can act on.
2
+
3
+ ToolNode reports pydantic errors verbatim ("Input should be a valid list"), which
4
+ does not tell the model *how* the payload was wrong. Models then resend the same
5
+ arguments with only the prose reworded, and the call fails identically. This
6
+ middleware re-validates the arguments on the error path and replaces the content
7
+ with the offending value, the expectation, and the parameter schema.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any, Callable
13
+
14
+ from langchain.agents.middleware import AgentMiddleware
15
+ from langchain_core.messages import ToolMessage
16
+ from pydantic import BaseModel, ValidationError
17
+
18
+
19
+ _MAX_INPUT_CHARS = 160
20
+ _MAX_SCHEMA_CHARS = 2500
21
+
22
+ _EXPECTATIONS = {
23
+ "list_type": "必须是原生 JSON 数组,不要把结构序列化成字符串",
24
+ "dict_type": "必须是原生 JSON 对象,不要把结构序列化成字符串",
25
+ "string_type": "必须是字符串",
26
+ "bool_type": "必须是布尔值 true / false",
27
+ "int_type": "必须是整数",
28
+ "float_type": "必须是数字",
29
+ "missing": "必填参数,不能省略",
30
+ "literal_error": "取值不在允许的枚举内,见下方 schema",
31
+ "enum": "取值不在允许的枚举内,见下方 schema",
32
+ "string_pattern_mismatch": "字符串格式不符合要求,见下方 schema",
33
+ "too_short": "长度不足,见下方 schema",
34
+ "model_attributes_type": "结构不符合该参数的 schema,见下方 schema",
35
+ "model_type": "必须是 JSON 对象,见下方 schema",
36
+ }
37
+
38
+
39
+ class ToolArgHintMiddleware(AgentMiddleware):
40
+ """Rewrite argument validation errors so the model fixes the payload instead of resending it."""
41
+
42
+ def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
43
+ return _rewrite(request, handler(request))
44
+
45
+
46
+ def _rewrite(request: Any, result: Any) -> Any:
47
+ if not isinstance(result, ToolMessage) or result.status != "error":
48
+ return result
49
+ tool = getattr(request, "tool", None)
50
+ schema = getattr(tool, "tool_call_schema", None)
51
+ args = (getattr(request, "tool_call", None) or {}).get("args")
52
+ if not isinstance(schema, type) or not issubclass(schema, BaseModel):
53
+ return result
54
+ if not isinstance(args, dict):
55
+ return result
56
+ try:
57
+ schema.model_validate(args)
58
+ except ValidationError as exc:
59
+ content = _describe(str(getattr(tool, "name", "") or "tool"), exc, schema)
60
+ else:
61
+ # Arguments are valid, so the failure came from the tool body. Leave it alone.
62
+ return result
63
+ return ToolMessage(
64
+ content=content,
65
+ tool_call_id=result.tool_call_id,
66
+ name=result.name,
67
+ status="error",
68
+ )
69
+
70
+
71
+ def _describe(tool_name: str, exc: ValidationError, schema: type[BaseModel]) -> str:
72
+ lines = [
73
+ f"参数校验失败,`{tool_name}` 没有执行,也没有产生任何副作用。",
74
+ "请按下面的说明修正参数后重新调用,不要原样重发同一份参数。",
75
+ "",
76
+ "错误:",
77
+ ]
78
+ for error in exc.errors():
79
+ loc = ".".join(str(part) for part in error.get("loc") or ()) or "(root)"
80
+ value = error.get("input")
81
+ expectation = _EXPECTATIONS.get(str(error.get("type") or ""))
82
+ detail = f"- {loc}: 收到 {type(value).__name__}({error.get('msg')})"
83
+ if expectation:
84
+ detail += f" → {expectation}"
85
+ lines.append(detail)
86
+ preview = _preview(value)
87
+ if preview:
88
+ lines.append(f" 收到内容:{preview}")
89
+ lines.extend(["", f"`{tool_name}` 的参数 schema:", _schema_text(schema)])
90
+ return "\n".join(lines)
91
+
92
+
93
+ def _preview(value: Any) -> str:
94
+ try:
95
+ text = json.dumps(value, ensure_ascii=False, default=str)
96
+ except (TypeError, ValueError):
97
+ text = str(value)
98
+ text = text.replace("\n", " ")
99
+ if len(text) > _MAX_INPUT_CHARS:
100
+ return f"{text[:_MAX_INPUT_CHARS]}…"
101
+ return text
102
+
103
+
104
+ def _schema_text(schema: type[BaseModel]) -> str:
105
+ doc = schema.model_json_schema()
106
+ doc.pop("description", None)
107
+ text = json.dumps(_without_titles(doc), ensure_ascii=False, separators=(",", ":"))
108
+ if len(text) > _MAX_SCHEMA_CHARS:
109
+ return f"{text[:_MAX_SCHEMA_CHARS]}…(schema 已截断)"
110
+ return text
111
+
112
+
113
+ def _without_titles(node: Any) -> Any:
114
+ # pydantic 给每个字段生成的 "title" 元数据对模型没有信息量,只会挤占提示预算。
115
+ # properties / $defs 下面的 key 是参数名和模型名,必须原样保留。
116
+ if isinstance(node, dict):
117
+ kept: dict[str, Any] = {}
118
+ for key, value in node.items():
119
+ if key in ("properties", "$defs", "definitions"):
120
+ kept[key] = {
121
+ name: _without_titles(item) for name, item in value.items()
122
+ } if isinstance(value, dict) else value
123
+ elif key != "title":
124
+ kept[key] = _without_titles(value)
125
+ return kept
126
+ if isinstance(node, list):
127
+ return [_without_titles(item) for item in node]
128
+ return node
@@ -0,0 +1,38 @@
1
+ """Filesystem middleware capability check for the workspace shell wrapper."""
2
+ from __future__ import annotations
3
+
4
+ from deepagents.backends.protocol import BackendProtocol
5
+ from deepagents.middleware.filesystem import FilesystemMiddleware
6
+
7
+ from agent.sandbox import WorkspaceCompositeBackend
8
+
9
+
10
+ class WorkspaceFilesystemMiddleware(FilesystemMiddleware):
11
+ """Keep the separately registered execute tool visible to the model.
12
+
13
+ Deep Agents checks only a CompositeBackend's default route for shell
14
+ support. Our default route denies file access outside /workspace, while
15
+ WorkspaceCompositeBackend.execute delegates to the sandbox executor.
16
+ """
17
+
18
+ @property
19
+ def name(self) -> str:
20
+ # Deep Agents replaces its built-in middleware by matching this name.
21
+ return "FilesystemMiddleware"
22
+
23
+ def _unsupported_tools_and_execution_state(
24
+ self, tool_names: set[str | None],
25
+ ) -> tuple[set[str | None], bool, BackendProtocol | None]:
26
+ unsupported, execution_active, backend = super()._unsupported_tools_and_execution_state(tool_names)
27
+ if "execute" in tool_names and isinstance(self.backend, WorkspaceCompositeBackend):
28
+ unsupported.discard("execute")
29
+ execution_active = True
30
+ # Upstream builds its "virtual mounts vs. shell paths" prompt from
31
+ # the returned backend, judging reachability by the composite's
32
+ # default route. Our execute shell runs on the executor's
33
+ # filesystem, where /workspace is the shell root, so returning the
34
+ # composite would inject a bogus "/workspace is not accessible
35
+ # from the shell" notice. The executor is not a CompositeBackend,
36
+ # which makes that prompt empty.
37
+ backend = self.backend.executor
38
+ return unsupported, execution_active, backend
@@ -0,0 +1,60 @@
1
+ """Attach the pre-write file state to successful write_file results."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Awaitable, Callable
5
+
6
+ from deepagents.backends.protocol import BackendProtocol, LsResult
7
+ from langchain.agents.middleware import AgentMiddleware
8
+ from langchain_core.messages import ToolMessage
9
+
10
+ from agent.file_mutation import WriteOperation
11
+
12
+
13
+ def _operation(result: LsResult) -> WriteOperation | None:
14
+ error = result.error or ""
15
+ if error.endswith(": path_not_found"):
16
+ return "create"
17
+ if error.endswith(": not_a_directory"):
18
+ return "overwrite"
19
+ return None
20
+
21
+
22
+ def _write_path(request: Any) -> str | None:
23
+ call = getattr(request, "tool_call", None) or {}
24
+ if call.get("name") != "write_file":
25
+ return None
26
+ args = call.get("args")
27
+ if not isinstance(args, dict) or not isinstance(args.get("file_path"), str):
28
+ return None
29
+ return args["file_path"]
30
+
31
+
32
+ def _annotate(result: Any, operation: WriteOperation | None) -> Any:
33
+ if operation is None or not isinstance(result, ToolMessage) or result.status == "error":
34
+ return result
35
+ artifact = result.artifact if isinstance(result.artifact, dict) else {}
36
+ return result.model_copy(update={"artifact": {**artifact, "operation": operation}})
37
+
38
+
39
+ class WriteOperationMiddleware(AgentMiddleware):
40
+ """Classify a write from the backend state immediately before execution."""
41
+
42
+ def __init__(self, backend: BackendProtocol) -> None:
43
+ super().__init__()
44
+ self._backend = backend
45
+
46
+ def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
47
+ path = _write_path(request)
48
+ try:
49
+ operation = _operation(self._backend.ls(path)) if path is not None else None
50
+ except (OSError, RuntimeError, NotImplementedError):
51
+ operation = None
52
+ return _annotate(handler(request), operation)
53
+
54
+ async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
55
+ path = _write_path(request)
56
+ try:
57
+ operation = _operation(self._backend.ls(path)) if path is not None else None
58
+ except (OSError, RuntimeError, NotImplementedError):
59
+ operation = None
60
+ return _annotate(await handler(request), operation)
agent/network.py ADDED
@@ -0,0 +1,30 @@
1
+ """Per-execute NETWORK capability (physical gate via Bubblewrap netns)."""
2
+ from __future__ import annotations
3
+
4
+ from contextvars import ContextVar
5
+ from typing import Any
6
+
7
+ _EXECUTE_NETWORK: ContextVar[bool] = ContextVar("deep_agent_execute_network", default=False)
8
+
9
+
10
+ def network_requested(args: dict[str, Any] | None) -> bool:
11
+ """True when a tool call declares the NETWORK capability."""
12
+ if not args:
13
+ return False
14
+ value = args.get("network", False)
15
+ if isinstance(value, str):
16
+ return value.strip().lower() in {"1", "true", "yes", "on"}
17
+ return bool(value)
18
+
19
+
20
+ def get_execute_network() -> bool:
21
+ return bool(_EXECUTE_NETWORK.get())
22
+
23
+
24
+ def set_execute_network(enabled: bool):
25
+ """Set NETWORK for the current tool-call scope. Returns a reset token."""
26
+ return _EXECUTE_NETWORK.set(bool(enabled))
27
+
28
+
29
+ def reset_execute_network(token) -> None:
30
+ _EXECUTE_NETWORK.reset(token)
agent/permission.py ADDED
@@ -0,0 +1,80 @@
1
+ """Runtime tool-approval policy (ask vs sandboxed allow-all)."""
2
+ from __future__ import annotations
3
+
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+ from agent.sandbox import ExecutionMode
8
+
9
+
10
+ class PermissionMode(StrEnum):
11
+ """Two-state tool permission policy."""
12
+
13
+ ASK = "ask" # Side-effect tools and every execute pause for approval.
14
+ ALLOW = "allow" # Auto-approve all tools; sandbox network open (SANDBOXED only).
15
+
16
+
17
+ ASK_INTERRUPT_ON: dict[str, Any] = {
18
+ "execute": {"allowed_decisions": ["approve", "reject"]},
19
+ "write_file": True,
20
+ "edit_file": True,
21
+ "delete": True,
22
+ }
23
+
24
+ PERMISSION_ALLOW_WARNING = (
25
+ "HIGH RISK: Permission mode ALLOW auto-approves every tool call and any "
26
+ "capabilities it declares (including file writes and deletes), and runs "
27
+ "every execute with the sandbox network OPEN: full host network access "
28
+ "(internet, localhost, LAN). There is no per-call confirmation. Only "
29
+ "enable this if the agent is running in a SANDBOXED backend."
30
+ )
31
+
32
+
33
+ def allow_mode_available(execution_mode: ExecutionMode) -> bool:
34
+ """ALLOW is only valid when the backend is a known isolated sandbox."""
35
+ return execution_mode is ExecutionMode.SANDBOXED
36
+
37
+
38
+ def allow_mode_unavailable_reason(execution_mode: ExecutionMode) -> str:
39
+ return (
40
+ "ALLOW is only available when execution mode is SANDBOXED "
41
+ f"(current: {execution_mode.value})"
42
+ )
43
+
44
+
45
+ def interrupt_on_for_mode(mode: PermissionMode) -> dict[str, Any] | None:
46
+ """Return create_agent interrupt_on for the mode.
47
+
48
+ Empty mapping disables HumanInTheLoopMiddleware tool gates when the factory
49
+ treats an explicit mapping as a full replacement for the default CONFIRM set.
50
+ """
51
+ if mode is PermissionMode.ALLOW:
52
+ return {}
53
+ return dict(ASK_INTERRUPT_ON)
54
+
55
+
56
+ def permission_mode_from_interrupt_on(interrupt_on: dict[str, Any] | None) -> PermissionMode:
57
+ return PermissionMode.ALLOW if not interrupt_on else PermissionMode.ASK
58
+
59
+
60
+ def parse_permission_mode(value: str) -> PermissionMode | None:
61
+ key = value.strip().lower().replace("_", "-")
62
+ aliases = {
63
+ "ask": PermissionMode.ASK,
64
+ "confirm": PermissionMode.ASK,
65
+ "approve": PermissionMode.ASK,
66
+ "all-approve": PermissionMode.ASK,
67
+ "allapprove": PermissionMode.ASK,
68
+ "allow": PermissionMode.ALLOW,
69
+ "bypass": PermissionMode.ALLOW,
70
+ "yolo": PermissionMode.ALLOW,
71
+ "auto": PermissionMode.ALLOW,
72
+ "all-allow": PermissionMode.ALLOW,
73
+ }
74
+ return aliases.get(key)
75
+
76
+
77
+ def permission_mode_label(mode: PermissionMode) -> str:
78
+ if mode is PermissionMode.ALLOW:
79
+ return "allow (auto-approve all tools; sandbox network OPEN — SANDBOXED only, HIGH RISK)"
80
+ return "ask (side-effect tools and every execute require approval)"