coco-framework 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.
- coco/__init__.py +3 -0
- coco/agents/__init__.py +9 -0
- coco/agents/_subagent_transformer.py +307 -0
- coco/agents/factory.py +2164 -0
- coco/agents/middleware/__init__.py +100 -0
- coco/agents/middleware/_execution.py +385 -0
- coco/agents/middleware/_redaction.py +454 -0
- coco/agents/middleware/_retry.py +134 -0
- coco/agents/middleware/_trace_policy.py +61 -0
- coco/agents/middleware/context_editing.py +306 -0
- coco/agents/middleware/file_search.py +433 -0
- coco/agents/middleware/human_in_the_loop.py +680 -0
- coco/agents/middleware/internal_call_transformer.py +108 -0
- coco/agents/middleware/model_call_limit.py +267 -0
- coco/agents/middleware/model_fallback.py +417 -0
- coco/agents/middleware/model_retry.py +327 -0
- coco/agents/middleware/pii.py +878 -0
- coco/agents/middleware/provider_tool_search.py +307 -0
- coco/agents/middleware/shell_tool.py +949 -0
- coco/agents/middleware/summarization.py +910 -0
- coco/agents/middleware/todo.py +357 -0
- coco/agents/middleware/tool_call_limit.py +495 -0
- coco/agents/middleware/tool_emulator.py +251 -0
- coco/agents/middleware/tool_error.py +209 -0
- coco/agents/middleware/tool_retry.py +421 -0
- coco/agents/middleware/tool_selection.py +515 -0
- coco/agents/middleware/types.py +2201 -0
- coco/agents/structured_output.py +472 -0
- coco/chat_models/__init__.py +7 -0
- coco/chat_models/base.py +1082 -0
- coco/embeddings/__init__.py +18 -0
- coco/embeddings/base.py +279 -0
- coco/mcp/__init__.py +36 -0
- coco/mcp/adapter.py +245 -0
- coco/mcp/elicitation.py +394 -0
- coco/mcp/tools.py +307 -0
- coco/messages/__init__.py +73 -0
- coco/py.typed +0 -0
- coco/rate_limiters/__init__.py +13 -0
- coco/tools/__init__.py +22 -0
- coco/tools/tool_node.py +20 -0
- coco_anthropic/__init__.py +15 -0
- coco_anthropic/_client_utils.py +81 -0
- coco_anthropic/_compat.py +228 -0
- coco_anthropic/_sdk_compat.py +124 -0
- coco_anthropic/_version.py +3 -0
- coco_anthropic/chat_models.py +3349 -0
- coco_anthropic/data/__init__.py +1 -0
- coco_anthropic/data/_profiles.py +491 -0
- coco_anthropic/data/profile_augmentations.toml +69 -0
- coco_anthropic/experimental.py +140 -0
- coco_anthropic/llms.py +428 -0
- coco_anthropic/middleware/__init__.py +25 -0
- coco_anthropic/middleware/anthropic_tools.py +1197 -0
- coco_anthropic/middleware/bash.py +84 -0
- coco_anthropic/middleware/file_search.py +360 -0
- coco_anthropic/middleware/prompt_caching.py +276 -0
- coco_anthropic/output_parsers.py +93 -0
- coco_anthropic/py.typed +0 -0
- coco_core/__init__.py +20 -0
- coco_core/_api/__init__.py +87 -0
- coco_core/_api/beta_decorator.py +266 -0
- coco_core/_api/deprecation.py +637 -0
- coco_core/_api/internal.py +23 -0
- coco_core/_api/path.py +50 -0
- coco_core/_import_utils.py +41 -0
- coco_core/_protocol.py +625 -0
- coco_core/_security/__init__.py +36 -0
- coco_core/_security/_exceptions.py +9 -0
- coco_core/_security/_policy.py +313 -0
- coco_core/_security/_ssrf_protection.py +155 -0
- coco_core/_security/_transport.py +254 -0
- coco_core/agents.py +258 -0
- coco_core/caches.py +272 -0
- coco_core/callbacks/__init__.py +132 -0
- coco_core/callbacks/base.py +1229 -0
- coco_core/callbacks/file.py +266 -0
- coco_core/callbacks/manager.py +2826 -0
- coco_core/callbacks/stdout.py +123 -0
- coco_core/callbacks/streaming_stdout.py +154 -0
- coco_core/callbacks/usage.py +124 -0
- coco_core/chat_history.py +267 -0
- coco_core/chat_loaders.py +26 -0
- coco_core/chat_sessions.py +19 -0
- coco_core/cross_encoders.py +18 -0
- coco_core/document_loaders/__init__.py +36 -0
- coco_core/document_loaders/base.py +155 -0
- coco_core/document_loaders/blob_loaders.py +38 -0
- coco_core/documents/__init__.py +55 -0
- coco_core/documents/base.py +347 -0
- coco_core/documents/compressor.py +74 -0
- coco_core/documents/transformers.py +79 -0
- coco_core/embeddings/__init__.py +31 -0
- coco_core/embeddings/embeddings.py +78 -0
- coco_core/embeddings/fake.py +129 -0
- coco_core/env.py +22 -0
- coco_core/example_selectors/__init__.py +47 -0
- coco_core/example_selectors/base.py +60 -0
- coco_core/example_selectors/length_based.py +131 -0
- coco_core/example_selectors/semantic_similarity.py +366 -0
- coco_core/exceptions.py +166 -0
- coco_core/globals.py +72 -0
- coco_core/indexing/__init__.py +53 -0
- coco_core/indexing/api.py +954 -0
- coco_core/indexing/base.py +661 -0
- coco_core/indexing/in_memory.py +104 -0
- coco_core/language_models/__init__.py +116 -0
- coco_core/language_models/_compat_bridge.py +844 -0
- coco_core/language_models/_utils.py +343 -0
- coco_core/language_models/base.py +500 -0
- coco_core/language_models/chat_model_stream.py +1525 -0
- coco_core/language_models/chat_models.py +2748 -0
- coco_core/language_models/fake.py +137 -0
- coco_core/language_models/fake_chat_models.py +396 -0
- coco_core/language_models/llms.py +1569 -0
- coco_core/language_models/model_profile.py +183 -0
- coco_core/load/__init__.py +44 -0
- coco_core/load/_validation.py +191 -0
- coco_core/load/dump.py +120 -0
- coco_core/load/load.py +828 -0
- coco_core/load/mapping.py +1097 -0
- coco_core/load/serializable.py +406 -0
- coco_core/messages/__init__.py +198 -0
- coco_core/messages/ai.py +854 -0
- coco_core/messages/base.py +518 -0
- coco_core/messages/block_translators/__init__.py +112 -0
- coco_core/messages/block_translators/anthropic.py +534 -0
- coco_core/messages/block_translators/bedrock.py +108 -0
- coco_core/messages/block_translators/bedrock_converse.py +329 -0
- coco_core/messages/block_translators/coco_v0.py +303 -0
- coco_core/messages/block_translators/google_genai.py +610 -0
- coco_core/messages/block_translators/google_vertexai.py +21 -0
- coco_core/messages/block_translators/groq.py +157 -0
- coco_core/messages/block_translators/openai.py +1089 -0
- coco_core/messages/chat.py +64 -0
- coco_core/messages/content.py +1488 -0
- coco_core/messages/function.py +62 -0
- coco_core/messages/human.py +70 -0
- coco_core/messages/modifier.py +33 -0
- coco_core/messages/system.py +70 -0
- coco_core/messages/tool.py +418 -0
- coco_core/messages/utils.py +2442 -0
- coco_core/output_parsers/__init__.py +101 -0
- coco_core/output_parsers/base.py +361 -0
- coco_core/output_parsers/format_instructions.py +16 -0
- coco_core/output_parsers/json.py +139 -0
- coco_core/output_parsers/list.py +249 -0
- coco_core/output_parsers/openai_functions.py +324 -0
- coco_core/output_parsers/openai_tools.py +383 -0
- coco_core/output_parsers/pydantic.py +141 -0
- coco_core/output_parsers/string.py +63 -0
- coco_core/output_parsers/transform.py +175 -0
- coco_core/output_parsers/xml.py +301 -0
- coco_core/outputs/__init__.py +66 -0
- coco_core/outputs/chat_generation.py +147 -0
- coco_core/outputs/chat_result.py +39 -0
- coco_core/outputs/generation.py +80 -0
- coco_core/outputs/llm_result.py +112 -0
- coco_core/outputs/run_info.py +22 -0
- coco_core/prompt_values.py +161 -0
- coco_core/prompts/__init__.py +101 -0
- coco_core/prompts/base.py +508 -0
- coco_core/prompts/chat.py +1495 -0
- coco_core/prompts/dict.py +177 -0
- coco_core/prompts/few_shot.py +483 -0
- coco_core/prompts/few_shot_with_templates.py +239 -0
- coco_core/prompts/image.py +177 -0
- coco_core/prompts/loading.py +296 -0
- coco_core/prompts/message.py +97 -0
- coco_core/prompts/prompt.py +312 -0
- coco_core/prompts/string.py +399 -0
- coco_core/prompts/structured.py +214 -0
- coco_core/py.typed +0 -0
- coco_core/rate_limiters.py +254 -0
- coco_core/retrievers.py +328 -0
- coco_core/runnables/__init__.py +136 -0
- coco_core/runnables/base.py +6725 -0
- coco_core/runnables/branch.py +457 -0
- coco_core/runnables/config.py +712 -0
- coco_core/runnables/configurable.py +710 -0
- coco_core/runnables/fallbacks.py +663 -0
- coco_core/runnables/graph.py +741 -0
- coco_core/runnables/graph_ascii.py +366 -0
- coco_core/runnables/graph_mermaid.py +503 -0
- coco_core/runnables/graph_png.py +215 -0
- coco_core/runnables/history.py +631 -0
- coco_core/runnables/passthrough.py +849 -0
- coco_core/runnables/retry.py +379 -0
- coco_core/runnables/router.py +239 -0
- coco_core/runnables/schema.py +188 -0
- coco_core/runnables/utils.py +798 -0
- coco_core/stores.py +291 -0
- coco_core/structured_query.py +203 -0
- coco_core/sys_info.py +154 -0
- coco_core/tools/__init__.py +95 -0
- coco_core/tools/base.py +1948 -0
- coco_core/tools/convert.py +497 -0
- coco_core/tools/render.py +67 -0
- coco_core/tools/retriever.py +94 -0
- coco_core/tools/simple.py +208 -0
- coco_core/tools/structured.py +322 -0
- coco_core/tracers/__init__.py +47 -0
- coco_core/tracers/_compat.py +95 -0
- coco_core/tracers/_context.py +337 -0
- coco_core/tracers/_streaming.py +53 -0
- coco_core/tracers/base.py +955 -0
- coco_core/tracers/coco.py +279 -0
- coco_core/tracers/context.py +205 -0
- coco_core/tracers/core.py +766 -0
- coco_core/tracers/event_stream.py +1101 -0
- coco_core/tracers/log_stream.py +765 -0
- coco_core/tracers/memory_stream.py +200 -0
- coco_core/tracers/root_listeners.py +130 -0
- coco_core/tracers/run_collector.py +40 -0
- coco_core/tracers/schemas.py +14 -0
- coco_core/tracers/stdout.py +205 -0
- coco_core/utils/__init__.py +111 -0
- coco_core/utils/_gateway.py +274 -0
- coco_core/utils/_merge.py +218 -0
- coco_core/utils/aiter.py +357 -0
- coco_core/utils/env.py +86 -0
- coco_core/utils/formatting.py +80 -0
- coco_core/utils/function_calling.py +869 -0
- coco_core/utils/html.py +132 -0
- coco_core/utils/image.py +15 -0
- coco_core/utils/input.py +82 -0
- coco_core/utils/interactive_env.py +12 -0
- coco_core/utils/iter.py +229 -0
- coco_core/utils/json.py +230 -0
- coco_core/utils/json_schema.py +273 -0
- coco_core/utils/mustache.py +706 -0
- coco_core/utils/pydantic.py +631 -0
- coco_core/utils/strings.py +69 -0
- coco_core/utils/usage.py +61 -0
- coco_core/utils/utils.py +528 -0
- coco_core/utils/uuid.py +57 -0
- coco_core/vectorstores/__init__.py +53 -0
- coco_core/vectorstores/base.py +1111 -0
- coco_core/vectorstores/in_memory.py +546 -0
- coco_core/vectorstores/utils.py +163 -0
- coco_core/version.py +3 -0
- coco_framework-0.1.0.dist-info/METADATA +184 -0
- coco_framework-0.1.0.dist-info/RECORD +400 -0
- coco_framework-0.1.0.dist-info/WHEEL +4 -0
- coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
- coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
- coco_graph/_internal/__init__.py +4 -0
- coco_graph/_internal/_cache.py +31 -0
- coco_graph/_internal/_config.py +474 -0
- coco_graph/_internal/_constants.py +140 -0
- coco_graph/_internal/_fields.py +213 -0
- coco_graph/_internal/_future.py +220 -0
- coco_graph/_internal/_pydantic.py +275 -0
- coco_graph/_internal/_queue.py +124 -0
- coco_graph/_internal/_replay.py +90 -0
- coco_graph/_internal/_retry.py +29 -0
- coco_graph/_internal/_runnable.py +969 -0
- coco_graph/_internal/_scratchpad.py +19 -0
- coco_graph/_internal/_serde.py +253 -0
- coco_graph/_internal/_timeout.py +25 -0
- coco_graph/_internal/_typing.py +54 -0
- coco_graph/cache/base/__init__.py +48 -0
- coco_graph/cache/base/py.typed +0 -0
- coco_graph/cache/memory/__init__.py +73 -0
- coco_graph/cache/redis/__init__.py +144 -0
- coco_graph/cache/sqlite/__init__.py +120 -0
- coco_graph/callbacks.py +394 -0
- coco_graph/channels/__init__.py +29 -0
- coco_graph/channels/any_value.py +72 -0
- coco_graph/channels/base.py +121 -0
- coco_graph/channels/binop.py +155 -0
- coco_graph/channels/delta.py +202 -0
- coco_graph/channels/ephemeral_value.py +79 -0
- coco_graph/channels/last_value.py +151 -0
- coco_graph/channels/named_barrier_value.py +167 -0
- coco_graph/channels/topic.py +94 -0
- coco_graph/channels/untracked_value.py +73 -0
- coco_graph/checkpoint/base/__init__.py +861 -0
- coco_graph/checkpoint/base/id.py +109 -0
- coco_graph/checkpoint/base/py.typed +0 -0
- coco_graph/checkpoint/memory/__init__.py +698 -0
- coco_graph/checkpoint/memory/py.typed +0 -0
- coco_graph/checkpoint/postgres/__init__.py +602 -0
- coco_graph/checkpoint/postgres/_ainternal.py +23 -0
- coco_graph/checkpoint/postgres/_internal.py +21 -0
- coco_graph/checkpoint/postgres/aio.py +691 -0
- coco_graph/checkpoint/postgres/base.py +666 -0
- coco_graph/checkpoint/postgres/py.typed +0 -0
- coco_graph/checkpoint/postgres/shallow.py +967 -0
- coco_graph/checkpoint/serde/__init__.py +0 -0
- coco_graph/checkpoint/serde/_msgpack.py +97 -0
- coco_graph/checkpoint/serde/base.py +64 -0
- coco_graph/checkpoint/serde/encrypted.py +80 -0
- coco_graph/checkpoint/serde/event_hooks.py +52 -0
- coco_graph/checkpoint/serde/jsonplus.py +883 -0
- coco_graph/checkpoint/serde/py.typed +0 -0
- coco_graph/checkpoint/serde/types.py +68 -0
- coco_graph/checkpoint/sqlite/__init__.py +645 -0
- coco_graph/checkpoint/sqlite/_delta.py +172 -0
- coco_graph/checkpoint/sqlite/aio.py +738 -0
- coco_graph/checkpoint/sqlite/py.typed +0 -0
- coco_graph/checkpoint/sqlite/utils.py +116 -0
- coco_graph/config.py +196 -0
- coco_graph/constants.py +64 -0
- coco_graph/errors.py +241 -0
- coco_graph/func/__init__.py +620 -0
- coco_graph/graph/__init__.py +12 -0
- coco_graph/graph/_branch.py +225 -0
- coco_graph/graph/_node.py +103 -0
- coco_graph/graph/message.py +437 -0
- coco_graph/graph/state.py +1978 -0
- coco_graph/graph/ui.py +227 -0
- coco_graph/managed/__init__.py +3 -0
- coco_graph/managed/base.py +31 -0
- coco_graph/managed/is_last_step.py +24 -0
- coco_graph/prebuilt/__init__.py +23 -0
- coco_graph/prebuilt/_tool_call_stream.py +117 -0
- coco_graph/prebuilt/_tool_call_transformer.py +165 -0
- coco_graph/prebuilt/chat_agent_executor.py +1015 -0
- coco_graph/prebuilt/interrupt.py +105 -0
- coco_graph/prebuilt/py.typed +0 -0
- coco_graph/prebuilt/tool_node.py +2030 -0
- coco_graph/prebuilt/tool_validator.py +221 -0
- coco_graph/pregel/__init__.py +3 -0
- coco_graph/pregel/_algo.py +1460 -0
- coco_graph/pregel/_call.py +298 -0
- coco_graph/pregel/_checkpoint.py +331 -0
- coco_graph/pregel/_config.py +0 -0
- coco_graph/pregel/_draw.py +294 -0
- coco_graph/pregel/_executor.py +223 -0
- coco_graph/pregel/_io.py +174 -0
- coco_graph/pregel/_log.py +3 -0
- coco_graph/pregel/_loop.py +1988 -0
- coco_graph/pregel/_messages.py +461 -0
- coco_graph/pregel/_read.py +312 -0
- coco_graph/pregel/_retry.py +854 -0
- coco_graph/pregel/_runner.py +941 -0
- coco_graph/pregel/_tools.py +268 -0
- coco_graph/pregel/_utils.py +221 -0
- coco_graph/pregel/_validate.py +120 -0
- coco_graph/pregel/_write.py +192 -0
- coco_graph/pregel/debug.py +302 -0
- coco_graph/pregel/main.py +4364 -0
- coco_graph/pregel/protocol.py +288 -0
- coco_graph/pregel/types.py +38 -0
- coco_graph/py.typed +0 -0
- coco_graph/runtime.py +350 -0
- coco_graph/store/base/__init__.py +1322 -0
- coco_graph/store/base/batch.py +371 -0
- coco_graph/store/base/embed.py +436 -0
- coco_graph/store/base/py.typed +0 -0
- coco_graph/store/memory/__init__.py +592 -0
- coco_graph/store/memory/py.typed +0 -0
- coco_graph/store/postgres/__init__.py +4 -0
- coco_graph/store/postgres/aio.py +592 -0
- coco_graph/store/postgres/base.py +1480 -0
- coco_graph/store/postgres/py.typed +0 -0
- coco_graph/store/sqlite/__init__.py +4 -0
- coco_graph/store/sqlite/aio.py +632 -0
- coco_graph/store/sqlite/base.py +1514 -0
- coco_graph/stream/__init__.py +45 -0
- coco_graph/stream/_convert.py +32 -0
- coco_graph/stream/_mux.py +523 -0
- coco_graph/stream/_types.py +330 -0
- coco_graph/stream/run_stream.py +735 -0
- coco_graph/stream/stream_channel.py +341 -0
- coco_graph/stream/transformers.py +1039 -0
- coco_graph/types.py +1079 -0
- coco_graph/typing.py +48 -0
- coco_graph/utils/__init__.py +1 -0
- coco_graph/utils/config.py +4 -0
- coco_graph/utils/runnable.py +3 -0
- coco_graph/version.py +13 -0
- coco_graph/warnings.py +69 -0
- coco_openai/__init__.py +20 -0
- coco_openai/_compat.py +45 -0
- coco_openai/_version.py +3 -0
- coco_openai/chat_models/__init__.py +6 -0
- coco_openai/chat_models/_client_utils.py +684 -0
- coco_openai/chat_models/_compat.py +525 -0
- coco_openai/chat_models/azure.py +1227 -0
- coco_openai/chat_models/base.py +5654 -0
- coco_openai/chat_models/codex.py +557 -0
- coco_openai/chatgpt_oauth.py +1063 -0
- coco_openai/data/__init__.py +1 -0
- coco_openai/data/_profiles.py +1655 -0
- coco_openai/data/profile_augmentations.toml +127 -0
- coco_openai/embeddings/__init__.py +6 -0
- coco_openai/embeddings/azure.py +240 -0
- coco_openai/embeddings/base.py +820 -0
- coco_openai/llms/__init__.py +6 -0
- coco_openai/llms/azure.py +249 -0
- coco_openai/llms/base.py +919 -0
- coco_openai/middleware/__init__.py +11 -0
- coco_openai/middleware/openai_moderation.py +484 -0
- coco_openai/output_parsers/__init__.py +9 -0
- coco_openai/output_parsers/tools.py +9 -0
- coco_openai/py.typed +0 -0
- coco_openai/tools/__init__.py +5 -0
- coco_openai/tools/custom_tool.py +111 -0
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
"""Middleware that exposes a persistent shell tool to agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import queue
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
import weakref
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast, overload
|
|
19
|
+
|
|
20
|
+
from coco_core.messages import ToolMessage
|
|
21
|
+
from coco_core.runnables import run_in_executor
|
|
22
|
+
from coco_core.tools.base import ToolException
|
|
23
|
+
from coco_graph.channels.untracked_value import UntrackedValue
|
|
24
|
+
from pydantic import BaseModel, model_validator
|
|
25
|
+
from pydantic.json_schema import SkipJsonSchema
|
|
26
|
+
from typing_extensions import NotRequired, override
|
|
27
|
+
|
|
28
|
+
from coco.agents.middleware._execution import (
|
|
29
|
+
SHELL_TEMP_PREFIX,
|
|
30
|
+
BaseExecutionPolicy,
|
|
31
|
+
CodexSandboxExecutionPolicy,
|
|
32
|
+
DockerExecutionPolicy,
|
|
33
|
+
HostExecutionPolicy,
|
|
34
|
+
)
|
|
35
|
+
from coco.agents.middleware._redaction import (
|
|
36
|
+
PIIDetectionError,
|
|
37
|
+
PIIMatch,
|
|
38
|
+
RedactionRule,
|
|
39
|
+
ResolvedRedactionRule,
|
|
40
|
+
)
|
|
41
|
+
from coco.agents.middleware.types import (
|
|
42
|
+
AgentMiddleware,
|
|
43
|
+
AgentState,
|
|
44
|
+
ContextT,
|
|
45
|
+
PrivateStateAttr,
|
|
46
|
+
ResponseT,
|
|
47
|
+
)
|
|
48
|
+
from coco.tools import ToolRuntime, tool
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from collections.abc import Mapping, Sequence
|
|
52
|
+
|
|
53
|
+
from coco_graph.runtime import Runtime
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
LOGGER = logging.getLogger(__name__)
|
|
57
|
+
_DONE_MARKER_PREFIX = "__LC_SHELL_DONE__"
|
|
58
|
+
|
|
59
|
+
DEFAULT_TOOL_DESCRIPTION = (
|
|
60
|
+
"Execute a shell command inside a persistent session. Before running a command, "
|
|
61
|
+
"confirm the working directory is correct (e.g., inspect with `ls` or `pwd`) and ensure "
|
|
62
|
+
"any parent directories exist. Prefer absolute paths and quote paths containing spaces, "
|
|
63
|
+
'such as `cd "/path/with spaces"`. Chain multiple commands with `&&` or `;` instead of '
|
|
64
|
+
"embedding newlines. Avoid unnecessary `cd` usage unless explicitly required so the "
|
|
65
|
+
"session remains stable. Outputs may be truncated when they become very large, and long "
|
|
66
|
+
"running commands will be terminated once their configured timeout elapses."
|
|
67
|
+
)
|
|
68
|
+
SHELL_TOOL_NAME = "shell"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cleanup_resources(
|
|
72
|
+
session: ShellSession, tempdir: tempfile.TemporaryDirectory[str] | None, timeout: float
|
|
73
|
+
) -> None:
|
|
74
|
+
with contextlib.suppress(Exception):
|
|
75
|
+
session.stop(timeout)
|
|
76
|
+
if tempdir is not None:
|
|
77
|
+
with contextlib.suppress(Exception):
|
|
78
|
+
tempdir.cleanup()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class _SessionResources:
|
|
83
|
+
"""Container for per-run shell resources."""
|
|
84
|
+
|
|
85
|
+
session: ShellSession
|
|
86
|
+
tempdir: tempfile.TemporaryDirectory[str] | None
|
|
87
|
+
policy: BaseExecutionPolicy
|
|
88
|
+
finalizer: weakref.finalize = field(init=False, repr=False) # type: ignore[type-arg]
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
self.finalizer = weakref.finalize(
|
|
92
|
+
self,
|
|
93
|
+
_cleanup_resources,
|
|
94
|
+
self.session,
|
|
95
|
+
self.tempdir,
|
|
96
|
+
self.policy.termination_timeout,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ShellToolState(AgentState[ResponseT]):
|
|
101
|
+
"""Agent state extension for tracking shell session resources.
|
|
102
|
+
|
|
103
|
+
Type Parameters:
|
|
104
|
+
ResponseT: The type of the structured response. Defaults to `Any`.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
shell_session_resources: NotRequired[
|
|
108
|
+
Annotated[_SessionResources | None, UntrackedValue, PrivateStateAttr]
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class CommandExecutionResult:
|
|
114
|
+
"""Structured result from command execution."""
|
|
115
|
+
|
|
116
|
+
output: str
|
|
117
|
+
exit_code: int | None
|
|
118
|
+
timed_out: bool
|
|
119
|
+
truncated_by_lines: bool
|
|
120
|
+
truncated_by_bytes: bool
|
|
121
|
+
total_lines: int
|
|
122
|
+
total_bytes: int
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ShellSession:
|
|
126
|
+
"""Persistent shell session that supports sequential command execution."""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
workspace: Path,
|
|
131
|
+
policy: BaseExecutionPolicy,
|
|
132
|
+
command: tuple[str, ...],
|
|
133
|
+
environment: Mapping[str, str],
|
|
134
|
+
) -> None:
|
|
135
|
+
self._workspace = workspace
|
|
136
|
+
self._policy = policy
|
|
137
|
+
self._command = command
|
|
138
|
+
self._environment = dict(environment)
|
|
139
|
+
self._process: subprocess.Popen[str] | None = None
|
|
140
|
+
self._stdin: Any = None
|
|
141
|
+
self._queue: queue.Queue[tuple[str, str | None]] = queue.Queue()
|
|
142
|
+
self._lock = threading.Lock()
|
|
143
|
+
self._stdout_thread: threading.Thread | None = None
|
|
144
|
+
self._stderr_thread: threading.Thread | None = None
|
|
145
|
+
self._terminated = False
|
|
146
|
+
|
|
147
|
+
def start(self) -> None:
|
|
148
|
+
"""Start the shell subprocess and reader threads.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
RuntimeError: If the shell session pipes cannot be initialized.
|
|
152
|
+
"""
|
|
153
|
+
if self._process and self._process.poll() is None:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
self._process = self._policy.spawn(
|
|
157
|
+
workspace=self._workspace,
|
|
158
|
+
env=self._environment,
|
|
159
|
+
command=self._command,
|
|
160
|
+
)
|
|
161
|
+
if (
|
|
162
|
+
self._process.stdin is None
|
|
163
|
+
or self._process.stdout is None
|
|
164
|
+
or self._process.stderr is None
|
|
165
|
+
):
|
|
166
|
+
msg = "Failed to initialize shell session pipes."
|
|
167
|
+
raise RuntimeError(msg)
|
|
168
|
+
|
|
169
|
+
self._stdin = self._process.stdin
|
|
170
|
+
self._terminated = False
|
|
171
|
+
self._queue = queue.Queue()
|
|
172
|
+
|
|
173
|
+
self._stdout_thread = threading.Thread(
|
|
174
|
+
target=self._enqueue_stream,
|
|
175
|
+
args=(self._process.stdout, "stdout"),
|
|
176
|
+
daemon=True,
|
|
177
|
+
)
|
|
178
|
+
self._stderr_thread = threading.Thread(
|
|
179
|
+
target=self._enqueue_stream,
|
|
180
|
+
args=(self._process.stderr, "stderr"),
|
|
181
|
+
daemon=True,
|
|
182
|
+
)
|
|
183
|
+
self._stdout_thread.start()
|
|
184
|
+
self._stderr_thread.start()
|
|
185
|
+
|
|
186
|
+
def restart(self) -> None:
|
|
187
|
+
"""Restart the shell process."""
|
|
188
|
+
self.stop(self._policy.termination_timeout)
|
|
189
|
+
self.start()
|
|
190
|
+
|
|
191
|
+
def stop(self, timeout: float) -> None:
|
|
192
|
+
"""Stop the shell subprocess."""
|
|
193
|
+
if not self._process:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
if self._process.poll() is None and not self._terminated:
|
|
197
|
+
try:
|
|
198
|
+
self._stdin.write("exit\n")
|
|
199
|
+
self._stdin.flush()
|
|
200
|
+
except (BrokenPipeError, OSError):
|
|
201
|
+
LOGGER.debug(
|
|
202
|
+
"Failed to write exit command; terminating shell session.",
|
|
203
|
+
exc_info=True,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
if self._process.wait(timeout=timeout) is None:
|
|
208
|
+
self._kill_process()
|
|
209
|
+
except subprocess.TimeoutExpired:
|
|
210
|
+
self._kill_process()
|
|
211
|
+
finally:
|
|
212
|
+
self._terminated = True
|
|
213
|
+
with contextlib.suppress(Exception):
|
|
214
|
+
self._stdin.close()
|
|
215
|
+
self._process = None
|
|
216
|
+
|
|
217
|
+
def execute(self, command: str, *, timeout: float) -> CommandExecutionResult:
|
|
218
|
+
"""Execute a command in the persistent shell."""
|
|
219
|
+
if not self._process or self._process.poll() is not None:
|
|
220
|
+
msg = "Shell session is not running."
|
|
221
|
+
raise RuntimeError(msg)
|
|
222
|
+
|
|
223
|
+
marker = f"{_DONE_MARKER_PREFIX}{uuid.uuid4().hex}"
|
|
224
|
+
deadline = time.monotonic() + timeout
|
|
225
|
+
|
|
226
|
+
with self._lock:
|
|
227
|
+
self._drain_queue()
|
|
228
|
+
payload = command if command.endswith("\n") else f"{command}\n"
|
|
229
|
+
try:
|
|
230
|
+
self._stdin.write(payload)
|
|
231
|
+
self._stdin.write(f"printf '{marker} %s\\n' $?\n")
|
|
232
|
+
self._stdin.flush()
|
|
233
|
+
except (BrokenPipeError, OSError):
|
|
234
|
+
# The shell exited before we could write the marker command.
|
|
235
|
+
# This happens when commands like 'exit 1' terminate the shell.
|
|
236
|
+
return self._collect_output_after_exit(deadline)
|
|
237
|
+
|
|
238
|
+
return self._collect_output(marker, deadline, timeout)
|
|
239
|
+
|
|
240
|
+
def _collect_output(
|
|
241
|
+
self,
|
|
242
|
+
marker: str,
|
|
243
|
+
deadline: float,
|
|
244
|
+
timeout: float,
|
|
245
|
+
) -> CommandExecutionResult:
|
|
246
|
+
collected: list[str] = []
|
|
247
|
+
total_lines = 0
|
|
248
|
+
total_bytes = 0
|
|
249
|
+
truncated_by_lines = False
|
|
250
|
+
truncated_by_bytes = False
|
|
251
|
+
exit_code: int | None = None
|
|
252
|
+
timed_out = False
|
|
253
|
+
|
|
254
|
+
while True:
|
|
255
|
+
remaining = deadline - time.monotonic()
|
|
256
|
+
if remaining <= 0:
|
|
257
|
+
timed_out = True
|
|
258
|
+
break
|
|
259
|
+
try:
|
|
260
|
+
source, data = self._queue.get(timeout=remaining)
|
|
261
|
+
except queue.Empty:
|
|
262
|
+
timed_out = True
|
|
263
|
+
break
|
|
264
|
+
|
|
265
|
+
if data is None:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if source == "stdout" and data.startswith(marker):
|
|
269
|
+
_, _, status = data.partition(" ")
|
|
270
|
+
exit_code = self._safe_int(status.strip())
|
|
271
|
+
# Drain any remaining stderr that may have arrived concurrently.
|
|
272
|
+
# The stderr reader thread runs independently, so output might
|
|
273
|
+
# still be in flight when the stdout marker arrives.
|
|
274
|
+
self._drain_remaining_stderr(collected, deadline)
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
total_lines += 1
|
|
278
|
+
encoded = data.encode("utf-8", "replace")
|
|
279
|
+
total_bytes += len(encoded)
|
|
280
|
+
|
|
281
|
+
if total_lines > self._policy.max_output_lines:
|
|
282
|
+
truncated_by_lines = True
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
if (
|
|
286
|
+
self._policy.max_output_bytes is not None
|
|
287
|
+
and total_bytes > self._policy.max_output_bytes
|
|
288
|
+
):
|
|
289
|
+
truncated_by_bytes = True
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
if source == "stderr":
|
|
293
|
+
stripped = data.rstrip("\n")
|
|
294
|
+
collected.append(f"[stderr] {stripped}")
|
|
295
|
+
if data.endswith("\n"):
|
|
296
|
+
collected.append("\n")
|
|
297
|
+
else:
|
|
298
|
+
collected.append(data)
|
|
299
|
+
|
|
300
|
+
if timed_out:
|
|
301
|
+
LOGGER.warning(
|
|
302
|
+
"Command timed out after %.2f seconds; restarting shell session.",
|
|
303
|
+
timeout,
|
|
304
|
+
)
|
|
305
|
+
self.restart()
|
|
306
|
+
return CommandExecutionResult(
|
|
307
|
+
output="",
|
|
308
|
+
exit_code=None,
|
|
309
|
+
timed_out=True,
|
|
310
|
+
truncated_by_lines=truncated_by_lines,
|
|
311
|
+
truncated_by_bytes=truncated_by_bytes,
|
|
312
|
+
total_lines=total_lines,
|
|
313
|
+
total_bytes=total_bytes,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
output = "".join(collected)
|
|
317
|
+
return CommandExecutionResult(
|
|
318
|
+
output=output,
|
|
319
|
+
exit_code=exit_code,
|
|
320
|
+
timed_out=False,
|
|
321
|
+
truncated_by_lines=truncated_by_lines,
|
|
322
|
+
truncated_by_bytes=truncated_by_bytes,
|
|
323
|
+
total_lines=total_lines,
|
|
324
|
+
total_bytes=total_bytes,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _collect_output_after_exit(self, deadline: float) -> CommandExecutionResult:
|
|
328
|
+
"""Collect output after the shell exited unexpectedly.
|
|
329
|
+
|
|
330
|
+
Called when a `BrokenPipeError` occurs while writing to stdin, indicating the
|
|
331
|
+
shell process terminated (e.g., due to an 'exit' command).
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
deadline: Absolute time by which collection must complete.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
`CommandExecutionResult` with collected output and the process exit code.
|
|
338
|
+
"""
|
|
339
|
+
collected: list[str] = []
|
|
340
|
+
total_lines = 0
|
|
341
|
+
total_bytes = 0
|
|
342
|
+
truncated_by_lines = False
|
|
343
|
+
truncated_by_bytes = False
|
|
344
|
+
|
|
345
|
+
# Give reader threads a brief moment to enqueue any remaining output.
|
|
346
|
+
drain_timeout = 0.1
|
|
347
|
+
drain_deadline = min(time.monotonic() + drain_timeout, deadline)
|
|
348
|
+
|
|
349
|
+
while True:
|
|
350
|
+
remaining = drain_deadline - time.monotonic()
|
|
351
|
+
if remaining <= 0:
|
|
352
|
+
break
|
|
353
|
+
try:
|
|
354
|
+
source, data = self._queue.get(timeout=remaining)
|
|
355
|
+
except queue.Empty:
|
|
356
|
+
break
|
|
357
|
+
|
|
358
|
+
if data is None:
|
|
359
|
+
# EOF marker from a reader thread; continue draining.
|
|
360
|
+
continue
|
|
361
|
+
|
|
362
|
+
total_lines += 1
|
|
363
|
+
encoded = data.encode("utf-8", "replace")
|
|
364
|
+
total_bytes += len(encoded)
|
|
365
|
+
|
|
366
|
+
if total_lines > self._policy.max_output_lines:
|
|
367
|
+
truncated_by_lines = True
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
if (
|
|
371
|
+
self._policy.max_output_bytes is not None
|
|
372
|
+
and total_bytes > self._policy.max_output_bytes
|
|
373
|
+
):
|
|
374
|
+
truncated_by_bytes = True
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
if source == "stderr":
|
|
378
|
+
stripped = data.rstrip("\n")
|
|
379
|
+
collected.append(f"[stderr] {stripped}")
|
|
380
|
+
if data.endswith("\n"):
|
|
381
|
+
collected.append("\n")
|
|
382
|
+
else:
|
|
383
|
+
collected.append(data)
|
|
384
|
+
|
|
385
|
+
# Get exit code from the terminated process.
|
|
386
|
+
exit_code: int | None = None
|
|
387
|
+
if self._process:
|
|
388
|
+
exit_code = self._process.poll()
|
|
389
|
+
|
|
390
|
+
output = "".join(collected)
|
|
391
|
+
return CommandExecutionResult(
|
|
392
|
+
output=output,
|
|
393
|
+
exit_code=exit_code,
|
|
394
|
+
timed_out=False,
|
|
395
|
+
truncated_by_lines=truncated_by_lines,
|
|
396
|
+
truncated_by_bytes=truncated_by_bytes,
|
|
397
|
+
total_lines=total_lines,
|
|
398
|
+
total_bytes=total_bytes,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _kill_process(self) -> None:
|
|
402
|
+
if not self._process:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
if hasattr(os, "killpg"):
|
|
406
|
+
try:
|
|
407
|
+
child_pgid = os.getpgid(self._process.pid)
|
|
408
|
+
# Only send a group kill when the child has a dedicated process group.
|
|
409
|
+
# If the child shares our group, killpg would terminate the caller too,
|
|
410
|
+
# so fall through to the direct kill below. That direct kill reaps only
|
|
411
|
+
# the immediate child, so any descendants it spawned may be orphaned.
|
|
412
|
+
# This applies to HostExecutionPolicy(create_process_group=False), the
|
|
413
|
+
# only policy that runs the shell in the caller's process group.
|
|
414
|
+
if child_pgid != os.getpgrp():
|
|
415
|
+
os.killpg(child_pgid, signal.SIGKILL)
|
|
416
|
+
return
|
|
417
|
+
except ProcessLookupError:
|
|
418
|
+
# Process already gone; nothing left to kill.
|
|
419
|
+
return
|
|
420
|
+
except OSError:
|
|
421
|
+
# e.g. EPERM while querying or signaling the group. Don't leak the
|
|
422
|
+
# child silently; fall through to a direct kill.
|
|
423
|
+
LOGGER.warning(
|
|
424
|
+
"Group kill failed; falling back to direct kill.",
|
|
425
|
+
exc_info=True,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
self._process.kill()
|
|
430
|
+
except ProcessLookupError:
|
|
431
|
+
# Process exited between the check above and this kill; nothing to do.
|
|
432
|
+
pass
|
|
433
|
+
except OSError:
|
|
434
|
+
# The fallback kill can hit the same condition (e.g. EPERM) that routed us
|
|
435
|
+
# here. Log rather than let it escape the session shutdown path.
|
|
436
|
+
LOGGER.warning(
|
|
437
|
+
"Direct kill failed.",
|
|
438
|
+
exc_info=True,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _enqueue_stream(self, stream: Any, label: str) -> None:
|
|
442
|
+
for line in iter(stream.readline, ""):
|
|
443
|
+
self._queue.put((label, line))
|
|
444
|
+
self._queue.put((label, None))
|
|
445
|
+
|
|
446
|
+
def _drain_queue(self) -> None:
|
|
447
|
+
while True:
|
|
448
|
+
try:
|
|
449
|
+
self._queue.get_nowait()
|
|
450
|
+
except queue.Empty:
|
|
451
|
+
break
|
|
452
|
+
|
|
453
|
+
def _drain_remaining_stderr(
|
|
454
|
+
self, collected: list[str], deadline: float, drain_timeout: float = 0.05
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Drain any stderr output that arrived concurrently with the done marker.
|
|
457
|
+
|
|
458
|
+
The stdout and stderr reader threads run independently. When a command writes to
|
|
459
|
+
stderr just before exiting, the stderr output may still be in transit when the
|
|
460
|
+
done marker arrives on stdout. This method briefly polls the queue to capture
|
|
461
|
+
such output.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
collected: The list to append collected stderr lines to.
|
|
465
|
+
deadline: The original command deadline (used as an upper bound).
|
|
466
|
+
drain_timeout: Maximum time to wait for additional stderr output.
|
|
467
|
+
"""
|
|
468
|
+
drain_deadline = min(time.monotonic() + drain_timeout, deadline)
|
|
469
|
+
while True:
|
|
470
|
+
remaining = drain_deadline - time.monotonic()
|
|
471
|
+
if remaining <= 0:
|
|
472
|
+
break
|
|
473
|
+
try:
|
|
474
|
+
source, data = self._queue.get(timeout=remaining)
|
|
475
|
+
except queue.Empty:
|
|
476
|
+
break
|
|
477
|
+
if data is None or source != "stderr":
|
|
478
|
+
continue
|
|
479
|
+
stripped = data.rstrip("\n")
|
|
480
|
+
collected.append(f"[stderr] {stripped}")
|
|
481
|
+
if data.endswith("\n"):
|
|
482
|
+
collected.append("\n")
|
|
483
|
+
|
|
484
|
+
@staticmethod
|
|
485
|
+
def _safe_int(value: str) -> int | None:
|
|
486
|
+
with contextlib.suppress(ValueError):
|
|
487
|
+
return int(value)
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class _ShellToolInput(BaseModel):
|
|
492
|
+
"""Input schema for the persistent shell tool."""
|
|
493
|
+
|
|
494
|
+
command: str | None = None
|
|
495
|
+
"""The shell command to execute."""
|
|
496
|
+
|
|
497
|
+
restart: bool | None = None
|
|
498
|
+
"""Whether to restart the shell session."""
|
|
499
|
+
|
|
500
|
+
runtime: Annotated[Any, SkipJsonSchema()] = None
|
|
501
|
+
"""The runtime for the shell tool.
|
|
502
|
+
|
|
503
|
+
Included as a workaround at the moment bc args_schema doesn't work with
|
|
504
|
+
injected ToolRuntime.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
@model_validator(mode="after")
|
|
508
|
+
def validate_payload(self) -> _ShellToolInput:
|
|
509
|
+
if self.command is None and not self.restart:
|
|
510
|
+
msg = "Shell tool requires either 'command' or 'restart'."
|
|
511
|
+
raise ValueError(msg)
|
|
512
|
+
if self.command is not None and self.restart:
|
|
513
|
+
msg = "Specify only one of 'command' or 'restart'."
|
|
514
|
+
raise ValueError(msg)
|
|
515
|
+
return self
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class ShellToolMiddleware(AgentMiddleware[ShellToolState[ResponseT], ContextT, ResponseT]):
|
|
519
|
+
"""Middleware that registers a persistent shell tool for agents.
|
|
520
|
+
|
|
521
|
+
The middleware exposes a single long-lived shell session. Use the execution policy
|
|
522
|
+
to match your deployment's security posture:
|
|
523
|
+
|
|
524
|
+
* `HostExecutionPolicy` – full host access; best for trusted environments where the
|
|
525
|
+
agent already runs inside a container or VM that provides isolation.
|
|
526
|
+
* `CodexSandboxExecutionPolicy` – reuses the Codex CLI sandbox for additional
|
|
527
|
+
syscall/filesystem restrictions when the CLI is available.
|
|
528
|
+
* `DockerExecutionPolicy` – launches a separate Docker container for each agent run,
|
|
529
|
+
providing harder isolation, optional read-only root filesystems, and user
|
|
530
|
+
remapping.
|
|
531
|
+
|
|
532
|
+
When no policy is provided the middleware defaults to `HostExecutionPolicy`.
|
|
533
|
+
"""
|
|
534
|
+
|
|
535
|
+
state_schema = ShellToolState # type: ignore[assignment]
|
|
536
|
+
|
|
537
|
+
def __init__(
|
|
538
|
+
self,
|
|
539
|
+
workspace_root: str | Path | None = None,
|
|
540
|
+
*,
|
|
541
|
+
startup_commands: tuple[str, ...] | list[str] | str | None = None,
|
|
542
|
+
shutdown_commands: tuple[str, ...] | list[str] | str | None = None,
|
|
543
|
+
execution_policy: BaseExecutionPolicy | None = None,
|
|
544
|
+
redaction_rules: tuple[RedactionRule, ...] | list[RedactionRule] | None = None,
|
|
545
|
+
tool_description: str | None = None,
|
|
546
|
+
tool_name: str = SHELL_TOOL_NAME,
|
|
547
|
+
shell_command: Sequence[str] | str | None = None,
|
|
548
|
+
env: Mapping[str, Any] | None = None,
|
|
549
|
+
) -> None:
|
|
550
|
+
"""Initialize an instance of `ShellToolMiddleware`.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
workspace_root: Base directory for the shell session.
|
|
554
|
+
|
|
555
|
+
If omitted, a temporary directory is created when the agent starts and
|
|
556
|
+
removed when it ends.
|
|
557
|
+
startup_commands: Optional commands executed sequentially after the session
|
|
558
|
+
starts.
|
|
559
|
+
shutdown_commands: Optional commands executed before the session shuts down.
|
|
560
|
+
execution_policy: Execution policy controlling timeouts, output limits, and
|
|
561
|
+
resource configuration.
|
|
562
|
+
|
|
563
|
+
Defaults to `HostExecutionPolicy` for native execution.
|
|
564
|
+
redaction_rules: Optional redaction rules to sanitize command output before
|
|
565
|
+
returning it to the model.
|
|
566
|
+
|
|
567
|
+
!!! warning
|
|
568
|
+
Redaction rules are applied post execution and do not prevent
|
|
569
|
+
exfiltration of secrets or sensitive data when using
|
|
570
|
+
`HostExecutionPolicy`.
|
|
571
|
+
|
|
572
|
+
tool_description: Optional override for the registered shell tool
|
|
573
|
+
description.
|
|
574
|
+
tool_name: Name for the registered shell tool.
|
|
575
|
+
|
|
576
|
+
Defaults to `"shell"`.
|
|
577
|
+
shell_command: Optional shell executable (string) or argument sequence used
|
|
578
|
+
to launch the persistent session.
|
|
579
|
+
|
|
580
|
+
Defaults to an implementation-defined bash command.
|
|
581
|
+
env: Optional environment variables to supply to the shell session.
|
|
582
|
+
|
|
583
|
+
Values are coerced to strings before command execution. If omitted, the
|
|
584
|
+
session inherits the parent process environment.
|
|
585
|
+
"""
|
|
586
|
+
super().__init__()
|
|
587
|
+
self._workspace_root = Path(workspace_root) if workspace_root else None
|
|
588
|
+
self._tool_name = tool_name
|
|
589
|
+
self._shell_command = self._normalize_shell_command(shell_command)
|
|
590
|
+
self._environment = self._normalize_env(env)
|
|
591
|
+
if execution_policy is not None:
|
|
592
|
+
self._execution_policy = execution_policy
|
|
593
|
+
else:
|
|
594
|
+
self._execution_policy = HostExecutionPolicy()
|
|
595
|
+
rules = redaction_rules or ()
|
|
596
|
+
self._redaction_rules: tuple[ResolvedRedactionRule, ...] = tuple(
|
|
597
|
+
rule.resolve() for rule in rules
|
|
598
|
+
)
|
|
599
|
+
self._startup_commands = self._normalize_commands(startup_commands)
|
|
600
|
+
self._shutdown_commands = self._normalize_commands(shutdown_commands)
|
|
601
|
+
|
|
602
|
+
# Create a proper tool that executes directly (no interception needed)
|
|
603
|
+
description = tool_description or DEFAULT_TOOL_DESCRIPTION
|
|
604
|
+
|
|
605
|
+
@tool(self._tool_name, args_schema=_ShellToolInput, description=description)
|
|
606
|
+
def shell_tool(
|
|
607
|
+
*,
|
|
608
|
+
runtime: ToolRuntime[None, ShellToolState],
|
|
609
|
+
command: str | None = None,
|
|
610
|
+
restart: bool = False,
|
|
611
|
+
) -> ToolMessage | str:
|
|
612
|
+
resources = self._get_or_create_resources(runtime.state)
|
|
613
|
+
return self._run_shell_tool(
|
|
614
|
+
resources,
|
|
615
|
+
{"command": command, "restart": restart},
|
|
616
|
+
tool_call_id=runtime.tool_call_id,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
self._shell_tool = shell_tool
|
|
620
|
+
self.tools = [self._shell_tool]
|
|
621
|
+
|
|
622
|
+
@staticmethod
|
|
623
|
+
def _normalize_commands(
|
|
624
|
+
commands: tuple[str, ...] | list[str] | str | None,
|
|
625
|
+
) -> tuple[str, ...]:
|
|
626
|
+
if commands is None:
|
|
627
|
+
return ()
|
|
628
|
+
if isinstance(commands, str):
|
|
629
|
+
return (commands,)
|
|
630
|
+
return tuple(commands)
|
|
631
|
+
|
|
632
|
+
@staticmethod
|
|
633
|
+
def _normalize_shell_command(
|
|
634
|
+
shell_command: Sequence[str] | str | None,
|
|
635
|
+
) -> tuple[str, ...]:
|
|
636
|
+
if shell_command is None:
|
|
637
|
+
return ("/bin/bash",)
|
|
638
|
+
normalized = (shell_command,) if isinstance(shell_command, str) else tuple(shell_command)
|
|
639
|
+
if not normalized:
|
|
640
|
+
msg = "Shell command must contain at least one argument."
|
|
641
|
+
raise ValueError(msg)
|
|
642
|
+
return normalized
|
|
643
|
+
|
|
644
|
+
@staticmethod
|
|
645
|
+
def _normalize_env(env: Mapping[str, Any] | None) -> dict[str, str] | None:
|
|
646
|
+
if env is None:
|
|
647
|
+
return None
|
|
648
|
+
normalized: dict[str, str] = {}
|
|
649
|
+
for key, value in env.items():
|
|
650
|
+
if not isinstance(key, str):
|
|
651
|
+
msg = "Environment variable names must be strings." # type: ignore[unreachable]
|
|
652
|
+
raise TypeError(msg)
|
|
653
|
+
normalized[key] = str(value)
|
|
654
|
+
return normalized
|
|
655
|
+
|
|
656
|
+
@override
|
|
657
|
+
def before_agent(
|
|
658
|
+
self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]
|
|
659
|
+
) -> dict[str, Any] | None:
|
|
660
|
+
"""Start the shell session and run startup commands.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
state: The current agent state.
|
|
664
|
+
runtime: The runtime context.
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
Shell session resources to be stored in the agent state.
|
|
668
|
+
"""
|
|
669
|
+
resources = self._get_or_create_resources(state)
|
|
670
|
+
return {"shell_session_resources": resources}
|
|
671
|
+
|
|
672
|
+
async def abefore_agent(
|
|
673
|
+
self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]
|
|
674
|
+
) -> dict[str, Any] | None:
|
|
675
|
+
"""Async start the shell session and run startup commands.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
state: The current agent state.
|
|
679
|
+
runtime: The runtime context.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Shell session resources to be stored in the agent state.
|
|
683
|
+
"""
|
|
684
|
+
return await run_in_executor(None, self.before_agent, state, runtime)
|
|
685
|
+
|
|
686
|
+
@override
|
|
687
|
+
def after_agent(self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]) -> None:
|
|
688
|
+
"""Run shutdown commands and release resources when an agent completes."""
|
|
689
|
+
resources = state.get("shell_session_resources")
|
|
690
|
+
if not isinstance(resources, _SessionResources):
|
|
691
|
+
# Resources were never created, nothing to clean up
|
|
692
|
+
return
|
|
693
|
+
try:
|
|
694
|
+
self._run_shutdown_commands(resources.session)
|
|
695
|
+
finally:
|
|
696
|
+
resources.finalizer()
|
|
697
|
+
|
|
698
|
+
async def aafter_agent(
|
|
699
|
+
self, state: ShellToolState[ResponseT], runtime: Runtime[ContextT]
|
|
700
|
+
) -> None:
|
|
701
|
+
"""Async run shutdown commands and release resources when an agent completes."""
|
|
702
|
+
return self.after_agent(state, runtime)
|
|
703
|
+
|
|
704
|
+
def _get_or_create_resources(self, state: ShellToolState[ResponseT]) -> _SessionResources:
|
|
705
|
+
"""Get existing resources from state or create new ones if they don't exist.
|
|
706
|
+
|
|
707
|
+
This method enables resumability by checking if resources already exist in the state
|
|
708
|
+
(e.g., after an interrupt), and only creating new resources if they're not present.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
state: The agent state which may contain shell session resources.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
Session resources, either retrieved from state or newly created.
|
|
715
|
+
"""
|
|
716
|
+
resources = state.get("shell_session_resources")
|
|
717
|
+
if isinstance(resources, _SessionResources):
|
|
718
|
+
return resources
|
|
719
|
+
|
|
720
|
+
new_resources = self._create_resources()
|
|
721
|
+
# Cast needed to make state dict-like for mutation
|
|
722
|
+
cast("dict[str, Any]", state)["shell_session_resources"] = new_resources
|
|
723
|
+
return new_resources
|
|
724
|
+
|
|
725
|
+
def _create_resources(self) -> _SessionResources:
|
|
726
|
+
workspace = self._workspace_root
|
|
727
|
+
tempdir: tempfile.TemporaryDirectory[str] | None = None
|
|
728
|
+
if workspace is None:
|
|
729
|
+
tempdir = tempfile.TemporaryDirectory(prefix=SHELL_TEMP_PREFIX)
|
|
730
|
+
workspace_path = Path(tempdir.name)
|
|
731
|
+
else:
|
|
732
|
+
workspace_path = workspace
|
|
733
|
+
workspace_path.mkdir(parents=True, exist_ok=True)
|
|
734
|
+
|
|
735
|
+
session = ShellSession(
|
|
736
|
+
workspace_path,
|
|
737
|
+
self._execution_policy,
|
|
738
|
+
self._shell_command,
|
|
739
|
+
self._environment or {},
|
|
740
|
+
)
|
|
741
|
+
try:
|
|
742
|
+
session.start()
|
|
743
|
+
LOGGER.info("Started shell session in %s", workspace_path)
|
|
744
|
+
self._run_startup_commands(session)
|
|
745
|
+
except BaseException:
|
|
746
|
+
LOGGER.exception("Starting shell session failed; cleaning up resources.")
|
|
747
|
+
session.stop(self._execution_policy.termination_timeout)
|
|
748
|
+
if tempdir is not None:
|
|
749
|
+
tempdir.cleanup()
|
|
750
|
+
raise
|
|
751
|
+
|
|
752
|
+
return _SessionResources(session=session, tempdir=tempdir, policy=self._execution_policy)
|
|
753
|
+
|
|
754
|
+
def _run_startup_commands(self, session: ShellSession) -> None:
|
|
755
|
+
if not self._startup_commands:
|
|
756
|
+
return
|
|
757
|
+
for command in self._startup_commands:
|
|
758
|
+
result = session.execute(command, timeout=self._execution_policy.startup_timeout)
|
|
759
|
+
if result.timed_out or (result.exit_code not in {0, None}):
|
|
760
|
+
msg = f"Startup command '{command}' failed with exit code {result.exit_code}"
|
|
761
|
+
raise RuntimeError(msg)
|
|
762
|
+
|
|
763
|
+
def _run_shutdown_commands(self, session: ShellSession) -> None:
|
|
764
|
+
if not self._shutdown_commands:
|
|
765
|
+
return
|
|
766
|
+
for command in self._shutdown_commands:
|
|
767
|
+
try:
|
|
768
|
+
result = session.execute(command, timeout=self._execution_policy.command_timeout)
|
|
769
|
+
if result.timed_out:
|
|
770
|
+
LOGGER.warning("Shutdown command '%s' timed out.", command)
|
|
771
|
+
elif result.exit_code not in {0, None}:
|
|
772
|
+
LOGGER.warning(
|
|
773
|
+
"Shutdown command '%s' exited with %s.", command, result.exit_code
|
|
774
|
+
)
|
|
775
|
+
except (RuntimeError, ToolException, OSError) as exc:
|
|
776
|
+
LOGGER.warning(
|
|
777
|
+
"Failed to run shutdown command '%s': %s", command, exc, exc_info=True
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def _apply_redactions(self, content: str) -> tuple[str, dict[str, list[PIIMatch]]]:
|
|
781
|
+
"""Apply configured redaction rules to command output."""
|
|
782
|
+
matches_by_type: dict[str, list[PIIMatch]] = {}
|
|
783
|
+
updated = content
|
|
784
|
+
for rule in self._redaction_rules:
|
|
785
|
+
updated, matches = rule.apply(updated)
|
|
786
|
+
if matches:
|
|
787
|
+
matches_by_type.setdefault(rule.pii_type, []).extend(matches)
|
|
788
|
+
return updated, matches_by_type
|
|
789
|
+
|
|
790
|
+
@overload
|
|
791
|
+
def _run_shell_tool(
|
|
792
|
+
self,
|
|
793
|
+
resources: _SessionResources,
|
|
794
|
+
payload: dict[str, Any],
|
|
795
|
+
*,
|
|
796
|
+
tool_call_id: str,
|
|
797
|
+
) -> ToolMessage: ...
|
|
798
|
+
|
|
799
|
+
@overload
|
|
800
|
+
def _run_shell_tool(
|
|
801
|
+
self,
|
|
802
|
+
resources: _SessionResources,
|
|
803
|
+
payload: dict[str, Any],
|
|
804
|
+
*,
|
|
805
|
+
tool_call_id: None,
|
|
806
|
+
) -> str: ...
|
|
807
|
+
|
|
808
|
+
def _run_shell_tool(
|
|
809
|
+
self,
|
|
810
|
+
resources: _SessionResources,
|
|
811
|
+
payload: dict[str, Any],
|
|
812
|
+
*,
|
|
813
|
+
tool_call_id: str | None,
|
|
814
|
+
) -> ToolMessage | str:
|
|
815
|
+
session = resources.session
|
|
816
|
+
|
|
817
|
+
if payload.get("restart"):
|
|
818
|
+
LOGGER.info("Restarting shell session on request.")
|
|
819
|
+
try:
|
|
820
|
+
session.restart()
|
|
821
|
+
self._run_startup_commands(session)
|
|
822
|
+
except BaseException as err:
|
|
823
|
+
LOGGER.exception("Restarting shell session failed; session remains unavailable.")
|
|
824
|
+
msg = "Failed to restart shell session."
|
|
825
|
+
raise ToolException(msg) from err
|
|
826
|
+
message = "Shell session restarted."
|
|
827
|
+
return self._format_tool_message(message, tool_call_id, status="success")
|
|
828
|
+
|
|
829
|
+
command = payload.get("command")
|
|
830
|
+
if not command or not isinstance(command, str):
|
|
831
|
+
msg = "Shell tool expects a 'command' string when restart is not requested."
|
|
832
|
+
raise ToolException(msg)
|
|
833
|
+
|
|
834
|
+
LOGGER.info("Executing shell command: %s", command)
|
|
835
|
+
result = session.execute(command, timeout=self._execution_policy.command_timeout)
|
|
836
|
+
|
|
837
|
+
if result.timed_out:
|
|
838
|
+
timeout_seconds = self._execution_policy.command_timeout
|
|
839
|
+
message = f"Error: Command timed out after {timeout_seconds:.1f} seconds."
|
|
840
|
+
return self._format_tool_message(
|
|
841
|
+
message,
|
|
842
|
+
tool_call_id,
|
|
843
|
+
status="error",
|
|
844
|
+
artifact={
|
|
845
|
+
"timed_out": True,
|
|
846
|
+
"exit_code": None,
|
|
847
|
+
},
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
try:
|
|
851
|
+
sanitized_output, matches = self._apply_redactions(result.output)
|
|
852
|
+
except PIIDetectionError as error:
|
|
853
|
+
LOGGER.warning("Blocking command output due to detected %s.", error.pii_type)
|
|
854
|
+
message = f"Output blocked: detected {error.pii_type}."
|
|
855
|
+
return self._format_tool_message(
|
|
856
|
+
message,
|
|
857
|
+
tool_call_id,
|
|
858
|
+
status="error",
|
|
859
|
+
artifact={
|
|
860
|
+
"timed_out": False,
|
|
861
|
+
"exit_code": result.exit_code,
|
|
862
|
+
"matches": {error.pii_type: error.matches},
|
|
863
|
+
},
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
sanitized_output = sanitized_output or "<no output>"
|
|
867
|
+
if result.truncated_by_lines:
|
|
868
|
+
sanitized_output = (
|
|
869
|
+
f"{sanitized_output.rstrip()}\n\n"
|
|
870
|
+
f"... Output truncated at {self._execution_policy.max_output_lines} lines "
|
|
871
|
+
f"(observed {result.total_lines})."
|
|
872
|
+
)
|
|
873
|
+
if result.truncated_by_bytes and self._execution_policy.max_output_bytes is not None:
|
|
874
|
+
sanitized_output = (
|
|
875
|
+
f"{sanitized_output.rstrip()}\n\n"
|
|
876
|
+
f"... Output truncated at {self._execution_policy.max_output_bytes} bytes "
|
|
877
|
+
f"(observed {result.total_bytes})."
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
if result.exit_code not in {0, None}:
|
|
881
|
+
sanitized_output = f"{sanitized_output.rstrip()}\n\nExit code: {result.exit_code}"
|
|
882
|
+
final_status: Literal["success", "error"] = "error"
|
|
883
|
+
else:
|
|
884
|
+
final_status = "success"
|
|
885
|
+
|
|
886
|
+
artifact = {
|
|
887
|
+
"timed_out": False,
|
|
888
|
+
"exit_code": result.exit_code,
|
|
889
|
+
"truncated_by_lines": result.truncated_by_lines,
|
|
890
|
+
"truncated_by_bytes": result.truncated_by_bytes,
|
|
891
|
+
"total_lines": result.total_lines,
|
|
892
|
+
"total_bytes": result.total_bytes,
|
|
893
|
+
"redaction_matches": matches,
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return self._format_tool_message(
|
|
897
|
+
sanitized_output,
|
|
898
|
+
tool_call_id,
|
|
899
|
+
status=final_status,
|
|
900
|
+
artifact=artifact,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
@overload
|
|
904
|
+
def _format_tool_message(
|
|
905
|
+
self,
|
|
906
|
+
content: str,
|
|
907
|
+
tool_call_id: str,
|
|
908
|
+
*,
|
|
909
|
+
status: Literal["success", "error"],
|
|
910
|
+
artifact: dict[str, Any] | None = None,
|
|
911
|
+
) -> ToolMessage: ...
|
|
912
|
+
|
|
913
|
+
@overload
|
|
914
|
+
def _format_tool_message(
|
|
915
|
+
self,
|
|
916
|
+
content: str,
|
|
917
|
+
tool_call_id: None,
|
|
918
|
+
*,
|
|
919
|
+
status: Literal["success", "error"],
|
|
920
|
+
artifact: dict[str, Any] | None = None,
|
|
921
|
+
) -> str: ...
|
|
922
|
+
|
|
923
|
+
def _format_tool_message(
|
|
924
|
+
self,
|
|
925
|
+
content: str,
|
|
926
|
+
tool_call_id: str | None,
|
|
927
|
+
*,
|
|
928
|
+
status: Literal["success", "error"],
|
|
929
|
+
artifact: dict[str, Any] | None = None,
|
|
930
|
+
) -> ToolMessage | str:
|
|
931
|
+
artifact = artifact or {}
|
|
932
|
+
if tool_call_id is None:
|
|
933
|
+
return content
|
|
934
|
+
return ToolMessage(
|
|
935
|
+
content=content,
|
|
936
|
+
tool_call_id=tool_call_id,
|
|
937
|
+
name=self._tool_name,
|
|
938
|
+
status=status,
|
|
939
|
+
artifact=artifact,
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
__all__ = [
|
|
944
|
+
"CodexSandboxExecutionPolicy",
|
|
945
|
+
"DockerExecutionPolicy",
|
|
946
|
+
"HostExecutionPolicy",
|
|
947
|
+
"RedactionRule",
|
|
948
|
+
"ShellToolMiddleware",
|
|
949
|
+
]
|