pi-durable-python 1.3.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 (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,99 @@
1
+ """Public types and protocols of the runner-independent conformance suites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, NotRequired, Protocol, TypedDict
8
+
9
+ from ..env import ExecutionEnv
10
+ from ..types import Storage
11
+
12
+ ExpectLike = Callable[[Any, str | None], Any]
13
+ """A Vitest/Jest-style ``expect`` callable: it returns a matcher for the actual value."""
14
+
15
+
16
+ class StorageConformanceAssertions(Protocol):
17
+ """The assertion vocabulary the storage conformance cases use."""
18
+
19
+ def ok(self, value: object, message: str | None = None) -> None: ...
20
+
21
+ def strict_equal(self, actual: object, expected: object) -> None: ...
22
+
23
+ def deep_equal(self, actual: object, expected: object) -> None: ...
24
+
25
+ def partial_deep_equal(self, actual: object, expected: object) -> None: ...
26
+
27
+ def greater_than(self, actual: float, expected: float) -> None: ...
28
+
29
+ async def rejects(self, operation: Awaitable[object], message_includes: str) -> None: ...
30
+
31
+
32
+ StorageConformanceProvider = Callable[[Callable[[Storage], Awaitable[None]]], Awaitable[None]]
33
+ """Runs one case against a fresh storage, calling and awaiting ``use`` exactly once."""
34
+
35
+
36
+ class StorageConformanceOptions(TypedDict):
37
+ assertions: StorageConformanceAssertions
38
+ with_storage: StorageConformanceProvider
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class StorageConformanceCase:
43
+ """One runner-independent conformance case."""
44
+
45
+ name: str
46
+ run: Callable[[], Awaitable[None]]
47
+
48
+
49
+ class StorageConformanceRunner(Protocol):
50
+ """The subset of a Vitest/Jest-compatible runner the registration helper needs."""
51
+
52
+ expect: ExpectLike
53
+
54
+ def describe(self, name: str, suite: Callable[[], object]) -> object: ...
55
+
56
+ def it(
57
+ self,
58
+ name: str,
59
+ test: Callable[[], Awaitable[None]],
60
+ timeout_ms: int | None = None,
61
+ ) -> object: ...
62
+
63
+
64
+ EnvConformanceAssertions = StorageConformanceAssertions
65
+ """The assertion vocabulary the environment conformance cases use."""
66
+
67
+ EnvConformanceProvider = Callable[[Callable[[ExecutionEnv], Awaitable[None]]], Awaitable[None]]
68
+ """Calls ``use`` once with an environment whose ``cwd`` is a fresh, empty, writable directory."""
69
+
70
+
71
+ class EnvConformanceOptions(TypedDict):
72
+ assertions: EnvConformanceAssertions
73
+ with_env: EnvConformanceProvider
74
+ shell: NotRequired[Sequence[str]]
75
+ symlinks: NotRequired[bool]
76
+ list_files: NotRequired[bool]
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class EnvConformanceCase:
81
+ """One runner-independent environment conformance case."""
82
+
83
+ name: str
84
+ run: Callable[[], Awaitable[None]]
85
+ timeout_ms: int | None = None
86
+
87
+
88
+ __all__ = [
89
+ "EnvConformanceAssertions",
90
+ "EnvConformanceCase",
91
+ "EnvConformanceOptions",
92
+ "EnvConformanceProvider",
93
+ "ExpectLike",
94
+ "StorageConformanceAssertions",
95
+ "StorageConformanceCase",
96
+ "StorageConformanceOptions",
97
+ "StorageConformanceProvider",
98
+ "StorageConformanceRunner",
99
+ ]
@@ -0,0 +1,159 @@
1
+ """The ``tool_search`` tool, a copy of the coding agent's.
2
+
3
+ The model searches the deferred tools it is not offered yet, and the matches are loaded with
4
+ ``control.addTools``, so the next request offers them. Loading writes the conversation's
5
+ ``modelTools`` (its ``add``), so loaded tools stay offered on that branch, also after a compaction.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Mapping
12
+ from typing import Any, TypedDict, cast
13
+
14
+ from ..context import Context
15
+ from ..harness.define import define_extension, define_tool
16
+ from ..harness.types import Extension, ToolExecutionApi, ToolExecutionResult, ToolRegistration
17
+ from ..json import JsonValue
18
+ from ..search import SearchDocument, rank_documents
19
+
20
+ TOOL_SEARCH_TOOL_NAME = "tool_search"
21
+ DEFAULT_TOOL_SEARCH_LIMIT = 8
22
+
23
+ #: Fixed, so it does not change while deferred tools are registered, for example as MCP servers
24
+ #: connect.
25
+ TOOL_SEARCH_DESCRIPTION = (
26
+ "# Tool discovery\n\n"
27
+ "Searches over deferred tool metadata with BM25 and exposes matching tools for the next model "
28
+ "call.\n\n"
29
+ "Some of the tools, such as tools of MCP servers, may not have been provided to you upfront, "
30
+ f"and you should use this tool (`{TOOL_SEARCH_TOOL_NAME}`) to search for the required tools. "
31
+ f"For MCP tool discovery, always use `{TOOL_SEARCH_TOOL_NAME}`."
32
+ )
33
+
34
+ TOOL_SEARCH_SCHEMA: dict[str, Any] = {
35
+ "type": "object",
36
+ "properties": {
37
+ "query": {
38
+ "type": "string",
39
+ "description": "Search query for deferred tools.",
40
+ },
41
+ "limit": {
42
+ "type": "number",
43
+ "description": (f"Maximum number of tools to return. Defaults to {DEFAULT_TOOL_SEARCH_LIMIT}."),
44
+ },
45
+ },
46
+ "required": ["query"],
47
+ }
48
+
49
+
50
+ class ToolSearchToolDetails(TypedDict):
51
+ """``tool_search`` result details: the tools it loaded."""
52
+
53
+ loaded: list[str]
54
+
55
+
56
+ def _is_object(value: Any) -> bool:
57
+ return isinstance(value, Mapping)
58
+
59
+
60
+ def _schema_text(schema: Any, parts: list[str]) -> None:
61
+ """Schema descriptions and property names, recursively."""
62
+
63
+ if not _is_object(schema):
64
+ return
65
+ description = schema.get("description")
66
+ if isinstance(description, str):
67
+ parts.append(description)
68
+ properties = schema.get("properties")
69
+ if _is_object(properties):
70
+ for name, property_schema in properties.items():
71
+ parts.append(name)
72
+ _schema_text(property_schema, parts)
73
+ _schema_text(schema.get("items"), parts)
74
+ for key in ("anyOf", "oneOf", "allOf"):
75
+ variants = schema.get(key)
76
+ if isinstance(variants, (list, tuple)):
77
+ for variant in variants:
78
+ _schema_text(variant, parts)
79
+
80
+
81
+ def _search_text(tool: ToolRegistration) -> str:
82
+ """A tool's name, the name with ``_`` as spaces, its description, and its parameters' text."""
83
+
84
+ parts = [tool.name, tool.name.replace("_", " "), tool.description]
85
+ _schema_text(tool.parameters, parts)
86
+ return " ".join(part for part in parts if part.strip() != "")
87
+
88
+
89
+ def _positive_integer(value: Any) -> int | None:
90
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
91
+ return None
92
+ if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()):
93
+ return None
94
+ return int(value) if value > 0 else None
95
+
96
+
97
+ def _first_line(description: str) -> str:
98
+ return description.strip().split("\n", 1)[0].rstrip("\r")
99
+
100
+
101
+ async def _execute(args: Mapping[str, Any], api: ToolExecutionApi, context: Context) -> ToolExecutionResult:
102
+ query = args.get("query")
103
+ if not isinstance(query, str) or query.strip() == "":
104
+ raise ValueError("query must not be empty")
105
+ raw_limit = args.get("limit")
106
+ maximum = DEFAULT_TOOL_SEARCH_LIMIT if raw_limit is None else _positive_integer(raw_limit)
107
+ if maximum is None:
108
+ raise ValueError("limit must be a positive integer")
109
+ candidates = list((await api.agent(context)).deferred)
110
+ documents: list[SearchDocument] = [{"name": tool.name, "text": _search_text(tool)} for tool in candidates]
111
+ loaded = rank_documents(query, documents, maximum)
112
+ if not loaded:
113
+ text = "No matching tools found."
114
+ else:
115
+ by_name = {tool.name: tool for tool in candidates}
116
+ lines = [f"- {name}: {_first_line(by_name[name].description)}" for name in loaded]
117
+ text = (
118
+ f"Loaded {len(loaded)} tool{'' if len(loaded) == 1 else 's'}. "
119
+ "They are available from your next call:\n" + "\n".join(lines)
120
+ )
121
+ details: ToolSearchToolDetails = {"loaded": loaded}
122
+ result: ToolExecutionResult = {
123
+ "output": [{"type": "text", "text": text}],
124
+ "details": cast(JsonValue, details),
125
+ }
126
+ if loaded:
127
+ result["control"] = {"addTools": loaded}
128
+ return result
129
+
130
+
131
+ def create_tool_search_tool() -> ToolRegistration:
132
+ """The ``tool_search`` tool: rank ``Agent.deferred`` with BM25 and load the matches."""
133
+
134
+ return define_tool(
135
+ {
136
+ "name": TOOL_SEARCH_TOOL_NAME,
137
+ "description": TOOL_SEARCH_DESCRIPTION,
138
+ "parameters": TOOL_SEARCH_SCHEMA,
139
+ # Searching changes what the model is offered; scripts have their own `searchTools()`.
140
+ "callers": ["model"],
141
+ "replay": "safe",
142
+ "execute": _execute,
143
+ }
144
+ )
145
+
146
+
147
+ def create_tool_search_extension() -> Extension:
148
+ """The ``tool_search`` extension: select it where the model should find deferred tools."""
149
+
150
+ return define_extension({"name": "tool-search", "tools": [create_tool_search_tool()]})
151
+
152
+
153
+ __all__ = [
154
+ "DEFAULT_TOOL_SEARCH_LIMIT",
155
+ "TOOL_SEARCH_TOOL_NAME",
156
+ "ToolSearchToolDetails",
157
+ "create_tool_search_extension",
158
+ "create_tool_search_tool",
159
+ ]
@@ -0,0 +1,81 @@
1
+ """Built-in coding tools.
2
+
3
+ Port of the v1.3.0 ``src/tools/index.ts``. ``read``, ``write``, ``edit``, and ``bash`` use only
4
+ ``api.env``; nothing installs them automatically. PowerShell is excluded, and there is no image
5
+ implementation: ``read`` optionally takes an :class:`~pi_durable.tools.ImageProcessor`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from typing import Any
12
+
13
+ from ..harness.define import define_extension
14
+ from ..harness.types import Extension
15
+ from .bash import (
16
+ BashExecution,
17
+ BashPrepare,
18
+ BashToolInput,
19
+ BashToolOptions,
20
+ create_bash_tool,
21
+ )
22
+ from .edit import EditToolDetails, EditToolInput, create_edit_tool
23
+ from .image_processor import (
24
+ DEFAULT_IMAGE_LIMITS,
25
+ ImageLimits,
26
+ ImageProcessor,
27
+ PreparedImage,
28
+ )
29
+ from .read import ReadToolDetails, ReadToolInput, ReadToolOptions, create_read_tool
30
+ from .write import WriteToolInput, create_write_tool
31
+
32
+
33
+ def create_coding_tools(
34
+ options: ReadToolOptions | Mapping[str, Any] | None = None,
35
+ *,
36
+ images: ImageProcessor | None = None,
37
+ ) -> Extension:
38
+ """The ``coding-tools`` extension: ``read``, ``write``, ``edit``, and ``bash``.
39
+
40
+ ``images`` prepares the images ``read`` returns.
41
+ """
42
+
43
+ return define_extension(
44
+ {
45
+ "name": "coding-tools",
46
+ "tools": [
47
+ create_read_tool(options, images=images),
48
+ create_write_tool(),
49
+ create_edit_tool(),
50
+ create_bash_tool(),
51
+ ],
52
+ }
53
+ )
54
+
55
+
56
+ #: :func:`create_coding_tools` without an image processor.
57
+ CodingTools = create_coding_tools()
58
+
59
+
60
+ __all__ = [
61
+ "BashExecution",
62
+ "BashPrepare",
63
+ "BashToolInput",
64
+ "BashToolOptions",
65
+ "CodingTools",
66
+ "DEFAULT_IMAGE_LIMITS",
67
+ "EditToolDetails",
68
+ "EditToolInput",
69
+ "ImageLimits",
70
+ "ImageProcessor",
71
+ "PreparedImage",
72
+ "ReadToolDetails",
73
+ "ReadToolInput",
74
+ "ReadToolOptions",
75
+ "WriteToolInput",
76
+ "create_bash_tool",
77
+ "create_coding_tools",
78
+ "create_edit_tool",
79
+ "create_read_tool",
80
+ "create_write_tool",
81
+ ]
@@ -0,0 +1,43 @@
1
+ """Small readers shared by the coding tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+
10
+ def field(value: Any, name: str, default: Any = None) -> Any:
11
+ """A field of a mapping or object, as the wire and Python shapes vary."""
12
+
13
+ if isinstance(value, Mapping):
14
+ return value.get(name, default)
15
+ return getattr(value, name, default)
16
+
17
+
18
+ def field_either(value: Any, snake: str, camel: str, default: Any = None) -> Any:
19
+ """A snake_case field, falling back to the upstream camelCase spelling."""
20
+
21
+ found = field(value, snake)
22
+ return field(value, camel, default) if found is None else found
23
+
24
+
25
+ def js_number_string(value: Any) -> str:
26
+ """``String(number)`` for the numbers tool arguments may hold."""
27
+
28
+ if isinstance(value, bool):
29
+ return "true" if value else "false"
30
+ if isinstance(value, int):
31
+ return str(value)
32
+ if isinstance(value, float):
33
+ if math.isnan(value):
34
+ return "NaN"
35
+ if math.isinf(value):
36
+ return "Infinity" if value > 0 else "-Infinity"
37
+ if value.is_integer() and abs(value) < 1e21:
38
+ return str(int(value))
39
+ return repr(value)
40
+ return str(value)
41
+
42
+
43
+ __all__ = ["field", "field_either", "js_number_string"]
@@ -0,0 +1,250 @@
1
+ """The ``bash`` coding tool.
2
+
3
+ Port of the v1.3.0 ``src/tools/bash.ts`` without the excluded PowerShell tool. The command runs
4
+ through the call's execution environment, streams into the Harness's retained output window, spills
5
+ oversized output, and reports a nonzero exit as an error result.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import inspect
11
+ import math
12
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
13
+ from dataclasses import dataclass
14
+ from dataclasses import field as dataclass_field
15
+ from typing import Any, TypedDict, cast
16
+
17
+ from ..context import Context
18
+ from ..env import ExecutionError, Result, ShellExecOptions, ShellExecResult
19
+ from ..harness.define import define_tool
20
+ from ..harness.types import ToolDiagnostic, ToolExecutionApi, ToolExecutionResult, ToolRegistration
21
+ from ..truncate import DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES
22
+ from ._values import field, js_number_string
23
+ from .env import require_env
24
+
25
+ MAX_TIMEOUT_SECONDS = 2_147_483_647 / 1000
26
+
27
+
28
+ class BashToolInput(TypedDict):
29
+ """Arguments of one ``bash`` call."""
30
+
31
+ command: str
32
+ timeout: float
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class BashExecution:
37
+ """A command about to run, which ``prepare`` may change."""
38
+
39
+ command: str = ""
40
+ cwd: str = ""
41
+ env: dict[str, str] = dataclass_field(default_factory=dict)
42
+ inherit_env: bool = True
43
+
44
+
45
+ BashPrepare = Callable[[BashExecution, ToolExecutionApi, Context], Awaitable[None] | None]
46
+
47
+
48
+ class BashToolOptions(TypedDict, total=False):
49
+ """Options of :func:`create_bash_tool`."""
50
+
51
+ command_prefix: str
52
+ prepare: BashPrepare
53
+
54
+
55
+ BASH_SCHEMA: dict[str, Any] = {
56
+ "type": "object",
57
+ "properties": {
58
+ "command": {"type": "string", "description": "Bash command to execute"},
59
+ "timeout": {
60
+ "type": "number",
61
+ "description": "Timeout in seconds (optional, no default timeout)",
62
+ },
63
+ },
64
+ "required": ["command"],
65
+ }
66
+
67
+ SHELL_STRUCTURED_OUTPUT_SCHEMA: dict[str, Any] = {
68
+ "type": "object",
69
+ "properties": {
70
+ "output": {
71
+ "type": "string",
72
+ "description": "Combined stdout and stderr: the retained tail, as the model sees it",
73
+ },
74
+ "truncated": {
75
+ "type": "boolean",
76
+ "description": "Whether earlier output was dropped",
77
+ },
78
+ "fullOutputPath": {
79
+ "type": "string",
80
+ "description": "File with the complete output, when it was spilled",
81
+ },
82
+ "exitCode": {"type": "number"},
83
+ },
84
+ "required": ["output", "truncated", "exitCode"],
85
+ }
86
+
87
+
88
+ def _validate_timeout(timeout: float | None) -> None:
89
+ if timeout is None:
90
+ return
91
+ if (
92
+ isinstance(timeout, bool)
93
+ or not isinstance(timeout, (int, float))
94
+ or not math.isfinite(timeout)
95
+ or timeout <= 0
96
+ ):
97
+ raise ValueError("Invalid timeout: must be a finite number of seconds")
98
+ if timeout > MAX_TIMEOUT_SECONDS:
99
+ raise ValueError(f"Invalid timeout: maximum is {js_number_string(MAX_TIMEOUT_SECONDS)} seconds")
100
+
101
+
102
+ async def _prepare_execution(
103
+ command: str,
104
+ options: Mapping[str, Any] | None,
105
+ api: ToolExecutionApi,
106
+ context: Context,
107
+ ) -> BashExecution:
108
+ values = dict(options or {})
109
+ prefix = values.get("command_prefix", values.get("commandPrefix"))
110
+ execution = BashExecution(
111
+ command=f"{prefix}\n{command}" if prefix else command,
112
+ cwd=require_env(api).cwd,
113
+ env={},
114
+ inherit_env=True,
115
+ )
116
+ prepare = values.get("prepare")
117
+ if prepare is not None:
118
+ prepared = prepare(execution, api, context)
119
+ if inspect.isawaitable(prepared):
120
+ await prepared
121
+ return execution
122
+
123
+
124
+ async def _run_command(
125
+ commands: Sequence[str | Sequence[str]],
126
+ execution: BashExecution,
127
+ timeout: float | None,
128
+ api: ToolExecutionApi,
129
+ context: Context,
130
+ ) -> ToolExecutionResult:
131
+ env = require_env(api)
132
+ result: Result[ShellExecResult, ExecutionError] | None = None
133
+ for command in commands:
134
+ options: dict[str, Any] = {
135
+ "cwd": execution.cwd,
136
+ "env": dict(execution.env),
137
+ "inherit_env": execution.inherit_env,
138
+ "on_output": lambda text, _context, info: api.output(text, field(info, "skipped")),
139
+ "spill": {"after_bytes": DEFAULT_MAX_BYTES, "after_lines": DEFAULT_MAX_LINES},
140
+ }
141
+ if timeout is not None:
142
+ options["timeout"] = timeout
143
+ # An environment may then omit output outside the retained tail and report the omission.
144
+ output_window = getattr(api, "output_window", None)
145
+ if output_window is not None:
146
+ options["window"] = output_window
147
+ result = await env.exec(command, cast(ShellExecOptions, options), context)
148
+ # A program that could not start produced no output; the next form may.
149
+ if result.ok or result.error.code != "spawn_error":
150
+ break
151
+ if result is None:
152
+ raise RuntimeError("No command to run")
153
+ spill_path = (
154
+ field(result.value, "spillPath", field(result.value, "spill_path"))
155
+ if result.ok
156
+ else field(result.error, "spill_path", field(result.error, "spillPath"))
157
+ )
158
+ if spill_path is not None:
159
+ api.diagnostic(
160
+ ToolDiagnostic(severity="info", code="full_output", message=f"Full output: {spill_path}")
161
+ )
162
+ if not result.ok:
163
+ error = result.error
164
+ if error.code == "aborted" and context.abort_signal is not None and context.abort_signal.aborted:
165
+ raise error
166
+ if error.code == "timeout":
167
+ raise RuntimeError(f"Command timed out after {js_number_string(timeout)} seconds")
168
+ if error.code == "aborted":
169
+ raise RuntimeError("Command aborted")
170
+ raise error
171
+ exit_code = result.value.get("exitCode")
172
+ retained = api.retained_output()
173
+ structured: dict[str, Any] = {
174
+ "output": field(retained, "text", ""),
175
+ "truncated": bool(field(retained, "truncated", False)),
176
+ "exitCode": exit_code,
177
+ }
178
+ if spill_path is not None:
179
+ structured["fullOutputPath"] = spill_path
180
+ if exit_code == 0:
181
+ return {"structuredOutput": structured}
182
+ return {
183
+ "structuredOutput": structured,
184
+ "isError": True,
185
+ "diagnostics": [
186
+ ToolDiagnostic(
187
+ severity="error",
188
+ code="exit_code",
189
+ message=f"Command exited with code {js_number_string(exit_code)}",
190
+ )
191
+ ],
192
+ }
193
+
194
+
195
+ def create_bash_tool(
196
+ options: BashToolOptions | Mapping[str, Any] | None = None,
197
+ *,
198
+ command_prefix: str | None = None,
199
+ prepare: BashPrepare | None = None,
200
+ ) -> ToolRegistration:
201
+ """Run a command through the environment's shell.
202
+
203
+ Its output streams to ``api.output()``, where the Harness keeps the tail within the default
204
+ limits; the result content is that retained output. Output beyond the limits is spilled to a
205
+ file whose path is reported as a diagnostic. A nonzero exit is an error result with the exit
206
+ code; a timeout throws.
207
+ """
208
+
209
+ values: dict[str, Any] = dict(options or {})
210
+ if command_prefix is not None:
211
+ values["command_prefix"] = command_prefix
212
+ if prepare is not None:
213
+ values["prepare"] = prepare
214
+
215
+ async def execute(
216
+ args: Mapping[str, Any], api: ToolExecutionApi, context: Context
217
+ ) -> ToolExecutionResult:
218
+ timeout = args.get("timeout")
219
+ _validate_timeout(timeout)
220
+ execution = await _prepare_execution(str(args["command"]), values, api, context)
221
+ return await _run_command([execution.command], execution, timeout, api, context)
222
+
223
+ return define_tool(
224
+ {
225
+ "name": "bash",
226
+ "description": (
227
+ "Execute a bash command in the current working directory. Returns combined stdout "
228
+ "and stderr. Output is truncated to last "
229
+ f"{DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit "
230
+ "first). If truncated, full output is saved to a temp file. Optionally provide a "
231
+ "timeout in seconds."
232
+ ),
233
+ "parameters": BASH_SCHEMA,
234
+ "structured_output_schema": SHELL_STRUCTURED_OUTPUT_SCHEMA,
235
+ "output_limits": {"retain": "tail"},
236
+ "execute": execute,
237
+ }
238
+ )
239
+
240
+
241
+ __all__ = [
242
+ "BASH_SCHEMA",
243
+ "MAX_TIMEOUT_SECONDS",
244
+ "SHELL_STRUCTURED_OUTPUT_SCHEMA",
245
+ "BashExecution",
246
+ "BashPrepare",
247
+ "BashToolInput",
248
+ "BashToolOptions",
249
+ "create_bash_tool",
250
+ ]