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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- 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"]
|
pi_durable/tools/bash.py
ADDED
|
@@ -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
|
+
]
|