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,1197 @@
|
|
|
1
|
+
"""Anthropic text editor and memory tool middleware.
|
|
2
|
+
|
|
3
|
+
This module provides client-side implementations of Anthropic's text editor and
|
|
4
|
+
memory tools using schema-less tool definitions and tool call interception.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Annotated, Any, cast
|
|
14
|
+
|
|
15
|
+
from coco.agents.middleware.types import (
|
|
16
|
+
AgentMiddleware,
|
|
17
|
+
AgentState,
|
|
18
|
+
ModelRequest,
|
|
19
|
+
ModelResponse,
|
|
20
|
+
_ModelRequestOverrides,
|
|
21
|
+
)
|
|
22
|
+
from coco.tools import ToolRuntime, tool
|
|
23
|
+
from coco_core.messages import SystemMessage, ToolMessage
|
|
24
|
+
from coco_graph.types import Command
|
|
25
|
+
from typing_extensions import NotRequired, TypedDict
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Tool type constants
|
|
32
|
+
TEXT_EDITOR_TOOL_TYPE = "text_editor_20250728"
|
|
33
|
+
TEXT_EDITOR_TOOL_NAME = "str_replace_based_edit_tool"
|
|
34
|
+
MEMORY_TOOL_TYPE = "memory_20250818"
|
|
35
|
+
MEMORY_TOOL_NAME = "memory"
|
|
36
|
+
|
|
37
|
+
MEMORY_SYSTEM_PROMPT = """IMPORTANT: ALWAYS VIEW YOUR MEMORY DIRECTORY BEFORE \
|
|
38
|
+
DOING ANYTHING ELSE.
|
|
39
|
+
MEMORY PROTOCOL:
|
|
40
|
+
1. Use the `view` command of your `memory` tool to check for earlier progress.
|
|
41
|
+
2. ... (work on the task) ...
|
|
42
|
+
- As you make progress, record status / progress / thoughts etc in your memory.
|
|
43
|
+
ASSUME INTERRUPTION: Your context window might be reset at any moment, so you risk \
|
|
44
|
+
losing any progress that is not recorded in your memory directory."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FileData(TypedDict):
|
|
48
|
+
"""Data structure for storing file contents."""
|
|
49
|
+
|
|
50
|
+
content: list[str]
|
|
51
|
+
"""Lines of the file."""
|
|
52
|
+
|
|
53
|
+
created_at: str
|
|
54
|
+
"""ISO 8601 timestamp of file creation."""
|
|
55
|
+
|
|
56
|
+
modified_at: str
|
|
57
|
+
"""ISO 8601 timestamp of last modification."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def files_reducer(
|
|
61
|
+
left: dict[str, FileData] | None, right: dict[str, FileData | None]
|
|
62
|
+
) -> dict[str, FileData]:
|
|
63
|
+
"""Custom reducer that merges file updates.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
left: Existing files dict.
|
|
67
|
+
right: New files dict to merge (`None` values delete files).
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Merged `dict` where right overwrites left for matching keys.
|
|
71
|
+
"""
|
|
72
|
+
if left is None:
|
|
73
|
+
# Filter out None values when initializing
|
|
74
|
+
return {k: v for k, v in right.items() if v is not None}
|
|
75
|
+
|
|
76
|
+
# Merge, filtering out None values (deletions)
|
|
77
|
+
result = {**left}
|
|
78
|
+
for k, v in right.items():
|
|
79
|
+
if v is None:
|
|
80
|
+
result.pop(k, None)
|
|
81
|
+
else:
|
|
82
|
+
result[k] = v
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AnthropicToolsState(AgentState):
|
|
87
|
+
"""State schema for Anthropic text editor and memory tools."""
|
|
88
|
+
|
|
89
|
+
text_editor_files: NotRequired[Annotated[dict[str, FileData], files_reducer]]
|
|
90
|
+
"""Virtual file system for text editor tools."""
|
|
91
|
+
|
|
92
|
+
memory_files: NotRequired[Annotated[dict[str, FileData], files_reducer]]
|
|
93
|
+
"""Virtual file system for memory tools."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_within_allowed_prefix(normalized: str, prefixes: Sequence[str]) -> bool:
|
|
97
|
+
"""Check whether a normalized path lies within an allowed prefix directory.
|
|
98
|
+
|
|
99
|
+
Uses a segment-boundary comparison rather than a raw string prefix test so
|
|
100
|
+
that sibling directories sharing a textual prefix cannot escape the allowed
|
|
101
|
+
directory. For example, with prefix `/memories` the path `/memories2/evil.txt`
|
|
102
|
+
is rejected because it is not the prefix itself nor a descendant of it.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
normalized: A normalized, forward-slash, absolute-style path.
|
|
106
|
+
prefixes: Allowed path prefixes to compare against.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
`True` if `normalized` exactly equals one of the prefix directories or is
|
|
110
|
+
contained within one of them, `False` otherwise.
|
|
111
|
+
"""
|
|
112
|
+
for prefix in prefixes:
|
|
113
|
+
# Normalize the prefix the same way the path was normalized so the
|
|
114
|
+
# comparison is consistent (drop any trailing slash for the boundary).
|
|
115
|
+
prefix_dir = prefix.rstrip("/")
|
|
116
|
+
if normalized == prefix_dir or normalized.startswith(f"{prefix_dir}/"):
|
|
117
|
+
return True
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _validate_path(path: str, *, allowed_prefixes: Sequence[str] | None = None) -> str:
|
|
122
|
+
"""Validate and normalize file path for security.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
path: The path to validate.
|
|
126
|
+
allowed_prefixes: Optional list of allowed path prefixes.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Normalized canonical path.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If path contains traversal sequences or violates prefix rules.
|
|
133
|
+
"""
|
|
134
|
+
# Reject paths with traversal attempts
|
|
135
|
+
if ".." in path or path.startswith("~"):
|
|
136
|
+
msg = f"Path traversal not allowed: {path}"
|
|
137
|
+
raise ValueError(msg)
|
|
138
|
+
|
|
139
|
+
# Normalize path (resolve ., //, etc.)
|
|
140
|
+
normalized = os.path.normpath(path)
|
|
141
|
+
|
|
142
|
+
# Convert to forward slashes for consistency
|
|
143
|
+
normalized = normalized.replace("\\", "/")
|
|
144
|
+
|
|
145
|
+
# Ensure path starts with /
|
|
146
|
+
if not normalized.startswith("/"):
|
|
147
|
+
normalized = f"/{normalized}"
|
|
148
|
+
|
|
149
|
+
# Check allowed prefixes if specified
|
|
150
|
+
if allowed_prefixes is not None and not _is_within_allowed_prefix(
|
|
151
|
+
normalized, allowed_prefixes
|
|
152
|
+
):
|
|
153
|
+
msg = f"Path must start with one of {allowed_prefixes}: {path}"
|
|
154
|
+
raise ValueError(msg)
|
|
155
|
+
|
|
156
|
+
return normalized
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _list_directory(files: dict[str, FileData], path: str) -> list[str]:
|
|
160
|
+
"""List files in a directory.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
files: Files `dict`.
|
|
164
|
+
path: Normalized directory path.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Sorted list of file paths in the directory.
|
|
168
|
+
"""
|
|
169
|
+
# Ensure path ends with / for directory matching
|
|
170
|
+
dir_path = path if path.endswith("/") else f"{path}/"
|
|
171
|
+
|
|
172
|
+
matching_files = []
|
|
173
|
+
for file_path in files:
|
|
174
|
+
if file_path.startswith(dir_path):
|
|
175
|
+
# Get relative path from directory
|
|
176
|
+
relative = file_path[len(dir_path) :]
|
|
177
|
+
# Only include direct children (no subdirectories)
|
|
178
|
+
if "/" not in relative:
|
|
179
|
+
matching_files.append(file_path)
|
|
180
|
+
|
|
181
|
+
return sorted(matching_files)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _StateClaudeFileToolMiddleware(AgentMiddleware):
|
|
185
|
+
"""Base class for state-based file tool middleware (internal)."""
|
|
186
|
+
|
|
187
|
+
state_schema = AnthropicToolsState
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
*,
|
|
192
|
+
tool_type: str,
|
|
193
|
+
tool_name: str,
|
|
194
|
+
state_key: str,
|
|
195
|
+
allowed_path_prefixes: Sequence[str] | None = None,
|
|
196
|
+
system_prompt: str | None = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Initialize.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
tool_type: Tool type identifier.
|
|
202
|
+
tool_name: Tool name.
|
|
203
|
+
state_key: State key for file storage.
|
|
204
|
+
allowed_path_prefixes: Optional list of allowed path prefixes.
|
|
205
|
+
system_prompt: Optional system prompt to inject.
|
|
206
|
+
"""
|
|
207
|
+
self.tool_type = tool_type
|
|
208
|
+
self.tool_name = tool_name
|
|
209
|
+
self.state_key = state_key
|
|
210
|
+
self.allowed_prefixes = allowed_path_prefixes
|
|
211
|
+
self.system_prompt = system_prompt
|
|
212
|
+
|
|
213
|
+
# Create tool that will be executed by the tool node
|
|
214
|
+
@tool(tool_name)
|
|
215
|
+
def file_tool(
|
|
216
|
+
runtime: ToolRuntime[None, AnthropicToolsState],
|
|
217
|
+
command: str,
|
|
218
|
+
path: str,
|
|
219
|
+
file_text: str | None = None,
|
|
220
|
+
old_str: str | None = None,
|
|
221
|
+
new_str: str | None = None,
|
|
222
|
+
insert_line: int | None = None,
|
|
223
|
+
new_path: str | None = None,
|
|
224
|
+
view_range: list[int] | None = None,
|
|
225
|
+
) -> Command | str:
|
|
226
|
+
"""Execute file operations on virtual file system.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
runtime: Tool runtime providing access to state.
|
|
230
|
+
command: Operation to perform.
|
|
231
|
+
path: File path to operate on.
|
|
232
|
+
file_text: Full file content for create command.
|
|
233
|
+
old_str: String to replace for str_replace command.
|
|
234
|
+
new_str: Replacement string for str_replace command.
|
|
235
|
+
insert_line: Line number for insert command.
|
|
236
|
+
new_path: New path for rename command.
|
|
237
|
+
view_range: Line range `[start, end]` for view command.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Command for state update or string result.
|
|
241
|
+
"""
|
|
242
|
+
# Build args dict for handler methods
|
|
243
|
+
# `old_path` is populated for `_handle_rename`, which reads the source
|
|
244
|
+
# path under that key instead of `path`.
|
|
245
|
+
args: dict[str, Any] = {"path": path, "old_path": path}
|
|
246
|
+
if file_text is not None:
|
|
247
|
+
args["file_text"] = file_text
|
|
248
|
+
if old_str is not None:
|
|
249
|
+
args["old_str"] = old_str
|
|
250
|
+
if new_str is not None:
|
|
251
|
+
args["new_str"] = new_str
|
|
252
|
+
if insert_line is not None:
|
|
253
|
+
args["insert_line"] = insert_line
|
|
254
|
+
if new_path is not None:
|
|
255
|
+
args["new_path"] = new_path
|
|
256
|
+
if view_range is not None:
|
|
257
|
+
args["view_range"] = view_range
|
|
258
|
+
|
|
259
|
+
# Route to appropriate handler based on command
|
|
260
|
+
try:
|
|
261
|
+
if command == "view":
|
|
262
|
+
return self._handle_view(args, runtime.state, runtime.tool_call_id)
|
|
263
|
+
if command == "create":
|
|
264
|
+
return self._handle_create(
|
|
265
|
+
args, runtime.state, runtime.tool_call_id
|
|
266
|
+
)
|
|
267
|
+
if command == "str_replace":
|
|
268
|
+
return self._handle_str_replace(
|
|
269
|
+
args, runtime.state, runtime.tool_call_id
|
|
270
|
+
)
|
|
271
|
+
if command == "insert":
|
|
272
|
+
return self._handle_insert(
|
|
273
|
+
args, runtime.state, runtime.tool_call_id
|
|
274
|
+
)
|
|
275
|
+
if command == "delete":
|
|
276
|
+
return self._handle_delete(
|
|
277
|
+
args, runtime.state, runtime.tool_call_id
|
|
278
|
+
)
|
|
279
|
+
if command == "rename":
|
|
280
|
+
return self._handle_rename(
|
|
281
|
+
args, runtime.state, runtime.tool_call_id
|
|
282
|
+
)
|
|
283
|
+
return f"Unknown command: {command}"
|
|
284
|
+
except (ValueError, FileNotFoundError) as e:
|
|
285
|
+
return str(e)
|
|
286
|
+
|
|
287
|
+
self.tools = [file_tool]
|
|
288
|
+
|
|
289
|
+
def wrap_model_call(
|
|
290
|
+
self,
|
|
291
|
+
request: ModelRequest,
|
|
292
|
+
handler: Callable[[ModelRequest], ModelResponse],
|
|
293
|
+
) -> ModelResponse:
|
|
294
|
+
"""Inject Anthropic tool descriptor and optional system prompt."""
|
|
295
|
+
# Replace our BaseTool with Anthropic's native tool descriptor
|
|
296
|
+
tools = [
|
|
297
|
+
t
|
|
298
|
+
for t in (request.tools or [])
|
|
299
|
+
if getattr(t, "name", None) != self.tool_name
|
|
300
|
+
] + [{"type": self.tool_type, "name": self.tool_name}]
|
|
301
|
+
|
|
302
|
+
# Inject system prompt if provided
|
|
303
|
+
overrides: _ModelRequestOverrides = {"tools": tools}
|
|
304
|
+
if self.system_prompt:
|
|
305
|
+
if request.system_message is not None:
|
|
306
|
+
new_system_content = [
|
|
307
|
+
*request.system_message.content_blocks,
|
|
308
|
+
{"type": "text", "text": f"\n\n{self.system_prompt}"},
|
|
309
|
+
]
|
|
310
|
+
else:
|
|
311
|
+
new_system_content = [{"type": "text", "text": self.system_prompt}]
|
|
312
|
+
new_system_message = SystemMessage(
|
|
313
|
+
content=cast("list[str | dict[str, str]]", new_system_content)
|
|
314
|
+
)
|
|
315
|
+
overrides["system_message"] = new_system_message
|
|
316
|
+
|
|
317
|
+
return handler(request.override(**overrides))
|
|
318
|
+
|
|
319
|
+
async def awrap_model_call(
|
|
320
|
+
self,
|
|
321
|
+
request: ModelRequest,
|
|
322
|
+
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
323
|
+
) -> ModelResponse:
|
|
324
|
+
"""Inject Anthropic tool descriptor and optional system prompt."""
|
|
325
|
+
# Replace our BaseTool with Anthropic's native tool descriptor
|
|
326
|
+
tools = [
|
|
327
|
+
t
|
|
328
|
+
for t in (request.tools or [])
|
|
329
|
+
if getattr(t, "name", None) != self.tool_name
|
|
330
|
+
] + [{"type": self.tool_type, "name": self.tool_name}]
|
|
331
|
+
|
|
332
|
+
# Inject system prompt if provided
|
|
333
|
+
overrides: _ModelRequestOverrides = {"tools": tools}
|
|
334
|
+
if self.system_prompt:
|
|
335
|
+
if request.system_message is not None:
|
|
336
|
+
new_system_content = [
|
|
337
|
+
*request.system_message.content_blocks,
|
|
338
|
+
{"type": "text", "text": f"\n\n{self.system_prompt}"},
|
|
339
|
+
]
|
|
340
|
+
else:
|
|
341
|
+
new_system_content = [{"type": "text", "text": self.system_prompt}]
|
|
342
|
+
new_system_message = SystemMessage(
|
|
343
|
+
content=cast("list[str | dict[str, str]]", new_system_content)
|
|
344
|
+
)
|
|
345
|
+
overrides["system_message"] = new_system_message
|
|
346
|
+
|
|
347
|
+
return await handler(request.override(**overrides))
|
|
348
|
+
|
|
349
|
+
def _handle_view(
|
|
350
|
+
self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
|
|
351
|
+
) -> Command:
|
|
352
|
+
"""Handle view command."""
|
|
353
|
+
path = args["path"]
|
|
354
|
+
normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
|
|
355
|
+
|
|
356
|
+
files = cast("dict[str, Any]", state.get(self.state_key, {}))
|
|
357
|
+
file_data = files.get(normalized_path)
|
|
358
|
+
|
|
359
|
+
if file_data is None:
|
|
360
|
+
# Try directory listing
|
|
361
|
+
matching = _list_directory(files, normalized_path)
|
|
362
|
+
|
|
363
|
+
if matching:
|
|
364
|
+
content = "\n".join(matching)
|
|
365
|
+
return Command(
|
|
366
|
+
update={
|
|
367
|
+
"messages": [
|
|
368
|
+
ToolMessage(
|
|
369
|
+
content=content,
|
|
370
|
+
tool_call_id=tool_call_id,
|
|
371
|
+
name=self.tool_name,
|
|
372
|
+
)
|
|
373
|
+
]
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
msg = f"File not found: {path}"
|
|
378
|
+
raise FileNotFoundError(msg)
|
|
379
|
+
|
|
380
|
+
# Format file content with line numbers
|
|
381
|
+
lines_content = file_data["content"]
|
|
382
|
+
formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines_content)]
|
|
383
|
+
content = "\n".join(formatted_lines)
|
|
384
|
+
|
|
385
|
+
return Command(
|
|
386
|
+
update={
|
|
387
|
+
"messages": [
|
|
388
|
+
ToolMessage(
|
|
389
|
+
content=content,
|
|
390
|
+
tool_call_id=tool_call_id,
|
|
391
|
+
name=self.tool_name,
|
|
392
|
+
)
|
|
393
|
+
]
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def _handle_create(
|
|
398
|
+
self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
|
|
399
|
+
) -> Command:
|
|
400
|
+
"""Handle create command."""
|
|
401
|
+
path = args["path"]
|
|
402
|
+
file_text = args["file_text"]
|
|
403
|
+
|
|
404
|
+
normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
|
|
405
|
+
|
|
406
|
+
# Get existing files
|
|
407
|
+
files = cast("dict[str, Any]", state.get(self.state_key, {}))
|
|
408
|
+
existing = files.get(normalized_path)
|
|
409
|
+
|
|
410
|
+
# Create file data
|
|
411
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
412
|
+
created_at = existing["created_at"] if existing else now
|
|
413
|
+
|
|
414
|
+
content_lines = file_text.split("\n")
|
|
415
|
+
|
|
416
|
+
return Command(
|
|
417
|
+
update={
|
|
418
|
+
self.state_key: {
|
|
419
|
+
normalized_path: {
|
|
420
|
+
"content": content_lines,
|
|
421
|
+
"created_at": created_at,
|
|
422
|
+
"modified_at": now,
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
"messages": [
|
|
426
|
+
ToolMessage(
|
|
427
|
+
content=f"File created: {path}",
|
|
428
|
+
tool_call_id=tool_call_id,
|
|
429
|
+
name=self.tool_name,
|
|
430
|
+
)
|
|
431
|
+
],
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def _handle_str_replace(
|
|
436
|
+
self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
|
|
437
|
+
) -> Command:
|
|
438
|
+
"""Handle str_replace command."""
|
|
439
|
+
path = args["path"]
|
|
440
|
+
old_str = args["old_str"]
|
|
441
|
+
new_str = args.get("new_str", "")
|
|
442
|
+
|
|
443
|
+
normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
|
|
444
|
+
|
|
445
|
+
# Read file
|
|
446
|
+
files = cast("dict[str, Any]", state.get(self.state_key, {}))
|
|
447
|
+
file_data = files.get(normalized_path)
|
|
448
|
+
if file_data is None:
|
|
449
|
+
msg = f"File not found: {path}"
|
|
450
|
+
raise FileNotFoundError(msg)
|
|
451
|
+
|
|
452
|
+
lines_content = file_data["content"]
|
|
453
|
+
content = "\n".join(lines_content)
|
|
454
|
+
|
|
455
|
+
# Replace string
|
|
456
|
+
if old_str not in content:
|
|
457
|
+
msg = f"String not found in file: {old_str}"
|
|
458
|
+
raise ValueError(msg)
|
|
459
|
+
|
|
460
|
+
new_content = content.replace(old_str, new_str, 1)
|
|
461
|
+
new_lines = new_content.split("\n")
|
|
462
|
+
|
|
463
|
+
# Update file
|
|
464
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
465
|
+
|
|
466
|
+
return Command(
|
|
467
|
+
update={
|
|
468
|
+
self.state_key: {
|
|
469
|
+
normalized_path: {
|
|
470
|
+
"content": new_lines,
|
|
471
|
+
"created_at": file_data["created_at"],
|
|
472
|
+
"modified_at": now,
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
"messages": [
|
|
476
|
+
ToolMessage(
|
|
477
|
+
content=f"String replaced in {path}",
|
|
478
|
+
tool_call_id=tool_call_id,
|
|
479
|
+
name=self.tool_name,
|
|
480
|
+
)
|
|
481
|
+
],
|
|
482
|
+
}
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def _handle_insert(
|
|
486
|
+
self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
|
|
487
|
+
) -> Command:
|
|
488
|
+
"""Handle insert command."""
|
|
489
|
+
path = args["path"]
|
|
490
|
+
insert_line = args["insert_line"]
|
|
491
|
+
text_to_insert = args["new_str"]
|
|
492
|
+
|
|
493
|
+
normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
|
|
494
|
+
|
|
495
|
+
# Read file
|
|
496
|
+
files = cast("dict[str, Any]", state.get(self.state_key, {}))
|
|
497
|
+
file_data = files.get(normalized_path)
|
|
498
|
+
if file_data is None:
|
|
499
|
+
msg = f"File not found: {path}"
|
|
500
|
+
raise FileNotFoundError(msg)
|
|
501
|
+
|
|
502
|
+
lines_content = file_data["content"]
|
|
503
|
+
new_lines = text_to_insert.split("\n")
|
|
504
|
+
|
|
505
|
+
# Insert after insert_line (0-indexed)
|
|
506
|
+
updated_lines = (
|
|
507
|
+
lines_content[:insert_line] + new_lines + lines_content[insert_line:]
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Update file
|
|
511
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
512
|
+
|
|
513
|
+
return Command(
|
|
514
|
+
update={
|
|
515
|
+
self.state_key: {
|
|
516
|
+
normalized_path: {
|
|
517
|
+
"content": updated_lines,
|
|
518
|
+
"created_at": file_data["created_at"],
|
|
519
|
+
"modified_at": now,
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
"messages": [
|
|
523
|
+
ToolMessage(
|
|
524
|
+
content=f"Text inserted in {path}",
|
|
525
|
+
tool_call_id=tool_call_id,
|
|
526
|
+
name=self.tool_name,
|
|
527
|
+
)
|
|
528
|
+
],
|
|
529
|
+
}
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
def _handle_delete(
|
|
533
|
+
self,
|
|
534
|
+
args: dict,
|
|
535
|
+
state: AnthropicToolsState,
|
|
536
|
+
tool_call_id: str | None,
|
|
537
|
+
) -> Command:
|
|
538
|
+
"""Handle delete command."""
|
|
539
|
+
path = args["path"]
|
|
540
|
+
|
|
541
|
+
normalized_path = _validate_path(path, allowed_prefixes=self.allowed_prefixes)
|
|
542
|
+
|
|
543
|
+
return Command(
|
|
544
|
+
update={
|
|
545
|
+
self.state_key: {normalized_path: None},
|
|
546
|
+
"messages": [
|
|
547
|
+
ToolMessage(
|
|
548
|
+
content=f"File deleted: {path}",
|
|
549
|
+
tool_call_id=tool_call_id,
|
|
550
|
+
name=self.tool_name,
|
|
551
|
+
)
|
|
552
|
+
],
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
def _handle_rename(
|
|
557
|
+
self, args: dict, state: AnthropicToolsState, tool_call_id: str | None
|
|
558
|
+
) -> Command:
|
|
559
|
+
"""Handle rename command."""
|
|
560
|
+
old_path = args["old_path"]
|
|
561
|
+
new_path = args["new_path"]
|
|
562
|
+
|
|
563
|
+
normalized_old = _validate_path(
|
|
564
|
+
old_path, allowed_prefixes=self.allowed_prefixes
|
|
565
|
+
)
|
|
566
|
+
normalized_new = _validate_path(
|
|
567
|
+
new_path, allowed_prefixes=self.allowed_prefixes
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Read file
|
|
571
|
+
files = cast("dict[str, Any]", state.get(self.state_key, {}))
|
|
572
|
+
file_data = files.get(normalized_old)
|
|
573
|
+
if file_data is None:
|
|
574
|
+
msg = f"File not found: {old_path}"
|
|
575
|
+
raise ValueError(msg)
|
|
576
|
+
|
|
577
|
+
# Update timestamp
|
|
578
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
579
|
+
file_data_copy = file_data.copy()
|
|
580
|
+
file_data_copy["modified_at"] = now
|
|
581
|
+
|
|
582
|
+
return Command(
|
|
583
|
+
update={
|
|
584
|
+
self.state_key: {
|
|
585
|
+
normalized_old: None,
|
|
586
|
+
normalized_new: file_data_copy,
|
|
587
|
+
},
|
|
588
|
+
"messages": [
|
|
589
|
+
ToolMessage(
|
|
590
|
+
content=f"File renamed: {old_path} -> {new_path}",
|
|
591
|
+
tool_call_id=tool_call_id,
|
|
592
|
+
name=self.tool_name,
|
|
593
|
+
)
|
|
594
|
+
],
|
|
595
|
+
}
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class StateClaudeTextEditorMiddleware(_StateClaudeFileToolMiddleware):
|
|
600
|
+
"""State-based text editor tool middleware.
|
|
601
|
+
|
|
602
|
+
Provides Anthropic's `text_editor` tool using CocoGraph state for storage.
|
|
603
|
+
Files persist for the conversation thread.
|
|
604
|
+
|
|
605
|
+
Example:
|
|
606
|
+
```python
|
|
607
|
+
from coco.agents import create_agent
|
|
608
|
+
from coco.agents.middleware import StateTextEditorToolMiddleware
|
|
609
|
+
|
|
610
|
+
agent = create_agent(
|
|
611
|
+
model=model,
|
|
612
|
+
tools=[],
|
|
613
|
+
middleware=[StateTextEditorToolMiddleware()],
|
|
614
|
+
)
|
|
615
|
+
```
|
|
616
|
+
"""
|
|
617
|
+
|
|
618
|
+
def __init__(
|
|
619
|
+
self,
|
|
620
|
+
*,
|
|
621
|
+
allowed_path_prefixes: Sequence[str] | None = None,
|
|
622
|
+
) -> None:
|
|
623
|
+
"""Initialize the text editor middleware.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
allowed_path_prefixes: Optional list of allowed path prefixes.
|
|
627
|
+
|
|
628
|
+
If specified, only paths starting with these prefixes are allowed.
|
|
629
|
+
"""
|
|
630
|
+
super().__init__(
|
|
631
|
+
tool_type=TEXT_EDITOR_TOOL_TYPE,
|
|
632
|
+
tool_name=TEXT_EDITOR_TOOL_NAME,
|
|
633
|
+
state_key="text_editor_files",
|
|
634
|
+
allowed_path_prefixes=allowed_path_prefixes,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class StateClaudeMemoryMiddleware(_StateClaudeFileToolMiddleware):
|
|
639
|
+
"""State-based memory tool middleware.
|
|
640
|
+
|
|
641
|
+
Provides Anthropic's memory tool using CocoGraph state for storage.
|
|
642
|
+
Files persist for the conversation thread.
|
|
643
|
+
|
|
644
|
+
Enforces `/memories` prefix and injects Anthropic's recommended system prompt.
|
|
645
|
+
|
|
646
|
+
Example:
|
|
647
|
+
```python
|
|
648
|
+
from coco.agents import create_agent
|
|
649
|
+
from coco.agents.middleware import StateMemoryToolMiddleware
|
|
650
|
+
|
|
651
|
+
agent = create_agent(
|
|
652
|
+
model=model,
|
|
653
|
+
tools=[],
|
|
654
|
+
middleware=[StateMemoryToolMiddleware()],
|
|
655
|
+
)
|
|
656
|
+
```
|
|
657
|
+
"""
|
|
658
|
+
|
|
659
|
+
def __init__(
|
|
660
|
+
self,
|
|
661
|
+
*,
|
|
662
|
+
allowed_path_prefixes: Sequence[str] | None = None,
|
|
663
|
+
system_prompt: str = MEMORY_SYSTEM_PROMPT,
|
|
664
|
+
) -> None:
|
|
665
|
+
"""Initialize the memory middleware.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
allowed_path_prefixes: Optional list of allowed path prefixes.
|
|
669
|
+
|
|
670
|
+
Defaults to `['/memories']`.
|
|
671
|
+
system_prompt: System prompt to inject.
|
|
672
|
+
|
|
673
|
+
Defaults to Anthropic's recommended memory prompt.
|
|
674
|
+
"""
|
|
675
|
+
super().__init__(
|
|
676
|
+
tool_type=MEMORY_TOOL_TYPE,
|
|
677
|
+
tool_name=MEMORY_TOOL_NAME,
|
|
678
|
+
state_key="memory_files",
|
|
679
|
+
allowed_path_prefixes=allowed_path_prefixes or ["/memories"],
|
|
680
|
+
system_prompt=system_prompt,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
class _FilesystemClaudeFileToolMiddleware(AgentMiddleware):
|
|
685
|
+
"""Base class for filesystem-based file tool middleware (internal)."""
|
|
686
|
+
|
|
687
|
+
def __init__(
|
|
688
|
+
self,
|
|
689
|
+
*,
|
|
690
|
+
tool_type: str,
|
|
691
|
+
tool_name: str,
|
|
692
|
+
root_path: str,
|
|
693
|
+
allowed_prefixes: list[str] | None = None,
|
|
694
|
+
max_file_size_mb: int = 10,
|
|
695
|
+
system_prompt: str | None = None,
|
|
696
|
+
) -> None:
|
|
697
|
+
"""Initialize.
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
tool_type: Tool type identifier.
|
|
701
|
+
tool_name: Tool name.
|
|
702
|
+
root_path: Root directory for file operations.
|
|
703
|
+
allowed_prefixes: Optional list of allowed virtual path prefixes.
|
|
704
|
+
max_file_size_mb: Maximum file size in MB.
|
|
705
|
+
system_prompt: Optional system prompt to inject.
|
|
706
|
+
"""
|
|
707
|
+
self.tool_type = tool_type
|
|
708
|
+
self.tool_name = tool_name
|
|
709
|
+
self.root_path = Path(root_path).resolve()
|
|
710
|
+
self.allowed_prefixes = allowed_prefixes or ["/"]
|
|
711
|
+
self.max_file_size_bytes = max_file_size_mb * 1024 * 1024
|
|
712
|
+
self.system_prompt = system_prompt
|
|
713
|
+
|
|
714
|
+
# Create root directory if it doesn't exist
|
|
715
|
+
self.root_path.mkdir(parents=True, exist_ok=True)
|
|
716
|
+
|
|
717
|
+
# Create tool that will be executed by the tool node
|
|
718
|
+
@tool(tool_name)
|
|
719
|
+
def file_tool(
|
|
720
|
+
runtime: ToolRuntime,
|
|
721
|
+
command: str,
|
|
722
|
+
path: str,
|
|
723
|
+
file_text: str | None = None,
|
|
724
|
+
old_str: str | None = None,
|
|
725
|
+
new_str: str | None = None,
|
|
726
|
+
insert_line: int | None = None,
|
|
727
|
+
new_path: str | None = None,
|
|
728
|
+
view_range: list[int] | None = None,
|
|
729
|
+
) -> Command | str:
|
|
730
|
+
"""Execute file operations on filesystem.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
runtime: Tool runtime providing `tool_call_id`.
|
|
734
|
+
command: Operation to perform.
|
|
735
|
+
path: File path to operate on.
|
|
736
|
+
file_text: Full file content for create command.
|
|
737
|
+
old_str: String to replace for `str_replace` command.
|
|
738
|
+
new_str: Replacement string for `str_replace` command.
|
|
739
|
+
insert_line: Line number for insert command.
|
|
740
|
+
new_path: New path for rename command.
|
|
741
|
+
view_range: Line range `[start, end]` for view command.
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
Command for message update or string result.
|
|
745
|
+
"""
|
|
746
|
+
# Build args dict for handler methods
|
|
747
|
+
# `old_path` is populated for `_handle_rename`, which reads the source
|
|
748
|
+
# path under that key instead of `path`.
|
|
749
|
+
args: dict[str, Any] = {"path": path, "old_path": path}
|
|
750
|
+
if file_text is not None:
|
|
751
|
+
args["file_text"] = file_text
|
|
752
|
+
if old_str is not None:
|
|
753
|
+
args["old_str"] = old_str
|
|
754
|
+
if new_str is not None:
|
|
755
|
+
args["new_str"] = new_str
|
|
756
|
+
if insert_line is not None:
|
|
757
|
+
args["insert_line"] = insert_line
|
|
758
|
+
if new_path is not None:
|
|
759
|
+
args["new_path"] = new_path
|
|
760
|
+
if view_range is not None:
|
|
761
|
+
args["view_range"] = view_range
|
|
762
|
+
|
|
763
|
+
# Route to appropriate handler based on command
|
|
764
|
+
try:
|
|
765
|
+
if command == "view":
|
|
766
|
+
return self._handle_view(args, runtime.tool_call_id)
|
|
767
|
+
if command == "create":
|
|
768
|
+
return self._handle_create(args, runtime.tool_call_id)
|
|
769
|
+
if command == "str_replace":
|
|
770
|
+
return self._handle_str_replace(args, runtime.tool_call_id)
|
|
771
|
+
if command == "insert":
|
|
772
|
+
return self._handle_insert(args, runtime.tool_call_id)
|
|
773
|
+
if command == "delete":
|
|
774
|
+
return self._handle_delete(args, runtime.tool_call_id)
|
|
775
|
+
if command == "rename":
|
|
776
|
+
return self._handle_rename(args, runtime.tool_call_id)
|
|
777
|
+
return f"Unknown command: {command}"
|
|
778
|
+
except (ValueError, FileNotFoundError, PermissionError) as e:
|
|
779
|
+
return str(e)
|
|
780
|
+
|
|
781
|
+
self.tools = [file_tool]
|
|
782
|
+
|
|
783
|
+
def wrap_model_call(
|
|
784
|
+
self,
|
|
785
|
+
request: ModelRequest,
|
|
786
|
+
handler: Callable[[ModelRequest], ModelResponse],
|
|
787
|
+
) -> ModelResponse:
|
|
788
|
+
"""Inject Anthropic tool descriptor and optional system prompt."""
|
|
789
|
+
# Replace our BaseTool with Anthropic's native tool descriptor
|
|
790
|
+
tools = [
|
|
791
|
+
t
|
|
792
|
+
for t in (request.tools or [])
|
|
793
|
+
if getattr(t, "name", None) != self.tool_name
|
|
794
|
+
] + [{"type": self.tool_type, "name": self.tool_name}]
|
|
795
|
+
|
|
796
|
+
# Inject system prompt if provided
|
|
797
|
+
overrides: _ModelRequestOverrides = {"tools": tools}
|
|
798
|
+
if self.system_prompt:
|
|
799
|
+
if request.system_message is not None:
|
|
800
|
+
new_system_content = [
|
|
801
|
+
*request.system_message.content_blocks,
|
|
802
|
+
{"type": "text", "text": f"\n\n{self.system_prompt}"},
|
|
803
|
+
]
|
|
804
|
+
else:
|
|
805
|
+
new_system_content = [{"type": "text", "text": self.system_prompt}]
|
|
806
|
+
new_system_message = SystemMessage(
|
|
807
|
+
content=cast("list[str | dict[str, str]]", new_system_content)
|
|
808
|
+
)
|
|
809
|
+
overrides["system_message"] = new_system_message
|
|
810
|
+
|
|
811
|
+
return handler(request.override(**overrides))
|
|
812
|
+
|
|
813
|
+
async def awrap_model_call(
|
|
814
|
+
self,
|
|
815
|
+
request: ModelRequest,
|
|
816
|
+
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
817
|
+
) -> ModelResponse:
|
|
818
|
+
"""Inject Anthropic tool descriptor and optional system prompt."""
|
|
819
|
+
# Replace our BaseTool with Anthropic's native tool descriptor
|
|
820
|
+
tools = [
|
|
821
|
+
t
|
|
822
|
+
for t in (request.tools or [])
|
|
823
|
+
if getattr(t, "name", None) != self.tool_name
|
|
824
|
+
] + [{"type": self.tool_type, "name": self.tool_name}]
|
|
825
|
+
|
|
826
|
+
# Inject system prompt if provided
|
|
827
|
+
overrides: _ModelRequestOverrides = {"tools": tools}
|
|
828
|
+
if self.system_prompt:
|
|
829
|
+
if request.system_message is not None:
|
|
830
|
+
new_system_content = [
|
|
831
|
+
*request.system_message.content_blocks,
|
|
832
|
+
{"type": "text", "text": f"\n\n{self.system_prompt}"},
|
|
833
|
+
]
|
|
834
|
+
else:
|
|
835
|
+
new_system_content = [{"type": "text", "text": self.system_prompt}]
|
|
836
|
+
new_system_message = SystemMessage(
|
|
837
|
+
content=cast("list[str | dict[str, str]]", new_system_content)
|
|
838
|
+
)
|
|
839
|
+
overrides["system_message"] = new_system_message
|
|
840
|
+
|
|
841
|
+
return await handler(request.override(**overrides))
|
|
842
|
+
|
|
843
|
+
def _validate_and_resolve_path(self, path: str) -> Path:
|
|
844
|
+
"""Validate and resolve a virtual path to filesystem path.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
path: Virtual path (e.g., `/file.txt` or `/src/main.py`).
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
Resolved absolute filesystem path within `root_path`.
|
|
851
|
+
|
|
852
|
+
Raises:
|
|
853
|
+
ValueError: If path contains traversal attempts, escapes root directory,
|
|
854
|
+
or violates `allowed_prefixes` restrictions.
|
|
855
|
+
"""
|
|
856
|
+
# Normalize path
|
|
857
|
+
if not path.startswith("/"):
|
|
858
|
+
path = "/" + path
|
|
859
|
+
|
|
860
|
+
# Check for path traversal
|
|
861
|
+
if ".." in path or "~" in path:
|
|
862
|
+
msg = "Path traversal not allowed"
|
|
863
|
+
raise ValueError(msg)
|
|
864
|
+
|
|
865
|
+
# Convert virtual path to filesystem path
|
|
866
|
+
# Remove leading / and resolve relative to root
|
|
867
|
+
relative = path.lstrip("/")
|
|
868
|
+
full_path = (self.root_path / relative).resolve()
|
|
869
|
+
|
|
870
|
+
# Ensure path is within root
|
|
871
|
+
try:
|
|
872
|
+
full_path.relative_to(self.root_path)
|
|
873
|
+
except ValueError:
|
|
874
|
+
msg = f"Path outside root directory: {path}"
|
|
875
|
+
raise ValueError(msg) from None
|
|
876
|
+
|
|
877
|
+
# Check allowed prefixes
|
|
878
|
+
virtual_path = "/" + str(full_path.relative_to(self.root_path))
|
|
879
|
+
if self.allowed_prefixes and not _is_within_allowed_prefix(
|
|
880
|
+
virtual_path, self.allowed_prefixes
|
|
881
|
+
):
|
|
882
|
+
msg = f"Path must start with one of: {self.allowed_prefixes}"
|
|
883
|
+
raise ValueError(msg)
|
|
884
|
+
|
|
885
|
+
return full_path
|
|
886
|
+
|
|
887
|
+
def _handle_view(self, args: dict, tool_call_id: str | None) -> Command:
|
|
888
|
+
"""Handle view command."""
|
|
889
|
+
path = args["path"]
|
|
890
|
+
full_path = self._validate_and_resolve_path(path)
|
|
891
|
+
|
|
892
|
+
if not full_path.exists() or not full_path.is_file():
|
|
893
|
+
msg = f"File not found: {path}"
|
|
894
|
+
raise FileNotFoundError(msg)
|
|
895
|
+
|
|
896
|
+
# Check file size
|
|
897
|
+
if full_path.stat().st_size > self.max_file_size_bytes:
|
|
898
|
+
max_mb = self.max_file_size_bytes / 1024 / 1024
|
|
899
|
+
msg = f"File too large: {path} exceeds {max_mb}MB"
|
|
900
|
+
raise ValueError(msg)
|
|
901
|
+
|
|
902
|
+
# Read file
|
|
903
|
+
try:
|
|
904
|
+
content = full_path.read_text()
|
|
905
|
+
except UnicodeDecodeError as e:
|
|
906
|
+
msg = f"Cannot decode file {path}: {e}"
|
|
907
|
+
raise ValueError(msg) from e
|
|
908
|
+
|
|
909
|
+
# Format with line numbers
|
|
910
|
+
lines = content.split("\n")
|
|
911
|
+
# Remove trailing newline's empty string if present
|
|
912
|
+
if lines and lines[-1] == "":
|
|
913
|
+
lines = lines[:-1]
|
|
914
|
+
formatted_lines = [f"{i + 1}|{line}" for i, line in enumerate(lines)]
|
|
915
|
+
formatted_content = "\n".join(formatted_lines)
|
|
916
|
+
|
|
917
|
+
return Command(
|
|
918
|
+
update={
|
|
919
|
+
"messages": [
|
|
920
|
+
ToolMessage(
|
|
921
|
+
content=formatted_content,
|
|
922
|
+
tool_call_id=tool_call_id,
|
|
923
|
+
name=self.tool_name,
|
|
924
|
+
)
|
|
925
|
+
]
|
|
926
|
+
}
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
def _handle_create(self, args: dict, tool_call_id: str | None) -> Command:
|
|
930
|
+
"""Handle create command."""
|
|
931
|
+
path = args["path"]
|
|
932
|
+
file_text = args["file_text"]
|
|
933
|
+
|
|
934
|
+
full_path = self._validate_and_resolve_path(path)
|
|
935
|
+
|
|
936
|
+
# Create parent directories
|
|
937
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
938
|
+
|
|
939
|
+
# Write file
|
|
940
|
+
full_path.write_text(file_text + "\n")
|
|
941
|
+
|
|
942
|
+
return Command(
|
|
943
|
+
update={
|
|
944
|
+
"messages": [
|
|
945
|
+
ToolMessage(
|
|
946
|
+
content=f"File created: {path}",
|
|
947
|
+
tool_call_id=tool_call_id,
|
|
948
|
+
name=self.tool_name,
|
|
949
|
+
)
|
|
950
|
+
]
|
|
951
|
+
}
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
def _handle_str_replace(self, args: dict, tool_call_id: str | None) -> Command:
|
|
955
|
+
"""Handle `str_replace` command."""
|
|
956
|
+
path = args["path"]
|
|
957
|
+
old_str = args["old_str"]
|
|
958
|
+
new_str = args.get("new_str", "")
|
|
959
|
+
|
|
960
|
+
full_path = self._validate_and_resolve_path(path)
|
|
961
|
+
|
|
962
|
+
if not full_path.exists():
|
|
963
|
+
msg = f"File not found: {path}"
|
|
964
|
+
raise FileNotFoundError(msg)
|
|
965
|
+
|
|
966
|
+
# Read file
|
|
967
|
+
content = full_path.read_text()
|
|
968
|
+
|
|
969
|
+
# Replace string
|
|
970
|
+
if old_str not in content:
|
|
971
|
+
msg = f"String not found in file: {old_str}"
|
|
972
|
+
raise ValueError(msg)
|
|
973
|
+
|
|
974
|
+
new_content = content.replace(old_str, new_str, 1)
|
|
975
|
+
|
|
976
|
+
# Write back
|
|
977
|
+
full_path.write_text(new_content)
|
|
978
|
+
|
|
979
|
+
return Command(
|
|
980
|
+
update={
|
|
981
|
+
"messages": [
|
|
982
|
+
ToolMessage(
|
|
983
|
+
content=f"String replaced in {path}",
|
|
984
|
+
tool_call_id=tool_call_id,
|
|
985
|
+
name=self.tool_name,
|
|
986
|
+
)
|
|
987
|
+
]
|
|
988
|
+
}
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
def _handle_insert(self, args: dict, tool_call_id: str | None) -> Command:
|
|
992
|
+
"""Handle insert command."""
|
|
993
|
+
path = args["path"]
|
|
994
|
+
insert_line = args["insert_line"]
|
|
995
|
+
text_to_insert = args["new_str"]
|
|
996
|
+
|
|
997
|
+
full_path = self._validate_and_resolve_path(path)
|
|
998
|
+
|
|
999
|
+
if not full_path.exists():
|
|
1000
|
+
msg = f"File not found: {path}"
|
|
1001
|
+
raise FileNotFoundError(msg)
|
|
1002
|
+
|
|
1003
|
+
# Read file
|
|
1004
|
+
content = full_path.read_text()
|
|
1005
|
+
lines = content.split("\n")
|
|
1006
|
+
# Handle trailing newline
|
|
1007
|
+
if lines and lines[-1] == "":
|
|
1008
|
+
lines = lines[:-1]
|
|
1009
|
+
had_trailing_newline = True
|
|
1010
|
+
else:
|
|
1011
|
+
had_trailing_newline = False
|
|
1012
|
+
|
|
1013
|
+
new_lines = text_to_insert.split("\n")
|
|
1014
|
+
|
|
1015
|
+
# Insert after insert_line (0-indexed)
|
|
1016
|
+
updated_lines = lines[:insert_line] + new_lines + lines[insert_line:]
|
|
1017
|
+
|
|
1018
|
+
# Write back
|
|
1019
|
+
new_content = "\n".join(updated_lines)
|
|
1020
|
+
if had_trailing_newline:
|
|
1021
|
+
new_content += "\n"
|
|
1022
|
+
full_path.write_text(new_content)
|
|
1023
|
+
|
|
1024
|
+
return Command(
|
|
1025
|
+
update={
|
|
1026
|
+
"messages": [
|
|
1027
|
+
ToolMessage(
|
|
1028
|
+
content=f"Text inserted in {path}",
|
|
1029
|
+
tool_call_id=tool_call_id,
|
|
1030
|
+
name=self.tool_name,
|
|
1031
|
+
)
|
|
1032
|
+
]
|
|
1033
|
+
}
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
def _handle_delete(self, args: dict, tool_call_id: str | None) -> Command:
|
|
1037
|
+
"""Handle delete command."""
|
|
1038
|
+
path = args["path"]
|
|
1039
|
+
full_path = self._validate_and_resolve_path(path)
|
|
1040
|
+
|
|
1041
|
+
if full_path.is_file():
|
|
1042
|
+
full_path.unlink()
|
|
1043
|
+
elif full_path.is_dir():
|
|
1044
|
+
shutil.rmtree(full_path)
|
|
1045
|
+
# If doesn't exist, silently succeed
|
|
1046
|
+
|
|
1047
|
+
return Command(
|
|
1048
|
+
update={
|
|
1049
|
+
"messages": [
|
|
1050
|
+
ToolMessage(
|
|
1051
|
+
content=f"File deleted: {path}",
|
|
1052
|
+
tool_call_id=tool_call_id,
|
|
1053
|
+
name=self.tool_name,
|
|
1054
|
+
)
|
|
1055
|
+
]
|
|
1056
|
+
}
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
def _handle_rename(self, args: dict, tool_call_id: str | None) -> Command:
|
|
1060
|
+
"""Handle rename command."""
|
|
1061
|
+
old_path = args["old_path"]
|
|
1062
|
+
new_path = args["new_path"]
|
|
1063
|
+
|
|
1064
|
+
old_full = self._validate_and_resolve_path(old_path)
|
|
1065
|
+
new_full = self._validate_and_resolve_path(new_path)
|
|
1066
|
+
|
|
1067
|
+
if not old_full.exists():
|
|
1068
|
+
msg = f"File not found: {old_path}"
|
|
1069
|
+
raise ValueError(msg)
|
|
1070
|
+
|
|
1071
|
+
# Create parent directory for new path
|
|
1072
|
+
new_full.parent.mkdir(parents=True, exist_ok=True)
|
|
1073
|
+
|
|
1074
|
+
# Rename
|
|
1075
|
+
old_full.rename(new_full)
|
|
1076
|
+
|
|
1077
|
+
return Command(
|
|
1078
|
+
update={
|
|
1079
|
+
"messages": [
|
|
1080
|
+
ToolMessage(
|
|
1081
|
+
content=f"File renamed: {old_path} -> {new_path}",
|
|
1082
|
+
tool_call_id=tool_call_id,
|
|
1083
|
+
name=self.tool_name,
|
|
1084
|
+
)
|
|
1085
|
+
]
|
|
1086
|
+
}
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
class FilesystemClaudeTextEditorMiddleware(_FilesystemClaudeFileToolMiddleware):
|
|
1091
|
+
"""Filesystem-based text editor tool middleware.
|
|
1092
|
+
|
|
1093
|
+
Provides Anthropic's `text_editor` tool using local filesystem for storage.
|
|
1094
|
+
User handles persistence via volumes, git, or other mechanisms.
|
|
1095
|
+
|
|
1096
|
+
Example:
|
|
1097
|
+
```python
|
|
1098
|
+
from coco.agents import create_agent
|
|
1099
|
+
from coco.agents.middleware import FilesystemTextEditorToolMiddleware
|
|
1100
|
+
|
|
1101
|
+
agent = create_agent(
|
|
1102
|
+
model=model,
|
|
1103
|
+
tools=[],
|
|
1104
|
+
middleware=[FilesystemTextEditorToolMiddleware(root_path="/workspace")],
|
|
1105
|
+
)
|
|
1106
|
+
```
|
|
1107
|
+
"""
|
|
1108
|
+
|
|
1109
|
+
def __init__(
|
|
1110
|
+
self,
|
|
1111
|
+
*,
|
|
1112
|
+
root_path: str,
|
|
1113
|
+
allowed_prefixes: list[str] | None = None,
|
|
1114
|
+
max_file_size_mb: int = 10,
|
|
1115
|
+
) -> None:
|
|
1116
|
+
"""Initialize the text editor middleware.
|
|
1117
|
+
|
|
1118
|
+
Args:
|
|
1119
|
+
root_path: Root directory for file operations.
|
|
1120
|
+
allowed_prefixes: Optional list of allowed virtual path prefixes.
|
|
1121
|
+
|
|
1122
|
+
Defaults to `['/']`.
|
|
1123
|
+
max_file_size_mb: Maximum file size in MB
|
|
1124
|
+
|
|
1125
|
+
Defaults to `10`.
|
|
1126
|
+
"""
|
|
1127
|
+
super().__init__(
|
|
1128
|
+
tool_type=TEXT_EDITOR_TOOL_TYPE,
|
|
1129
|
+
tool_name=TEXT_EDITOR_TOOL_NAME,
|
|
1130
|
+
root_path=root_path,
|
|
1131
|
+
allowed_prefixes=allowed_prefixes,
|
|
1132
|
+
max_file_size_mb=max_file_size_mb,
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
class FilesystemClaudeMemoryMiddleware(_FilesystemClaudeFileToolMiddleware):
|
|
1137
|
+
"""Filesystem-based memory tool middleware.
|
|
1138
|
+
|
|
1139
|
+
Provides Anthropic's memory tool using local filesystem for storage.
|
|
1140
|
+
User handles persistence via volumes, git, or other mechanisms.
|
|
1141
|
+
|
|
1142
|
+
Enforces `/memories` prefix and injects Anthropic's recommended system
|
|
1143
|
+
prompt.
|
|
1144
|
+
|
|
1145
|
+
Example:
|
|
1146
|
+
```python
|
|
1147
|
+
from coco.agents import create_agent
|
|
1148
|
+
from coco.agents.middleware import FilesystemMemoryToolMiddleware
|
|
1149
|
+
|
|
1150
|
+
agent = create_agent(
|
|
1151
|
+
model=model,
|
|
1152
|
+
tools=[],
|
|
1153
|
+
middleware=[FilesystemMemoryToolMiddleware(root_path="/workspace")],
|
|
1154
|
+
)
|
|
1155
|
+
```
|
|
1156
|
+
"""
|
|
1157
|
+
|
|
1158
|
+
def __init__(
|
|
1159
|
+
self,
|
|
1160
|
+
*,
|
|
1161
|
+
root_path: str,
|
|
1162
|
+
allowed_prefixes: list[str] | None = None,
|
|
1163
|
+
max_file_size_mb: int = 10,
|
|
1164
|
+
system_prompt: str = MEMORY_SYSTEM_PROMPT,
|
|
1165
|
+
) -> None:
|
|
1166
|
+
"""Initialize the memory middleware.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
root_path: Root directory for file operations.
|
|
1170
|
+
allowed_prefixes: Optional list of allowed virtual path prefixes.
|
|
1171
|
+
|
|
1172
|
+
Defaults to `['/memories']`.
|
|
1173
|
+
max_file_size_mb: Maximum file size in MB
|
|
1174
|
+
|
|
1175
|
+
Defaults to `10`.
|
|
1176
|
+
system_prompt: System prompt to inject.
|
|
1177
|
+
|
|
1178
|
+
Defaults to Anthropic's recommended memory prompt.
|
|
1179
|
+
"""
|
|
1180
|
+
super().__init__(
|
|
1181
|
+
tool_type=MEMORY_TOOL_TYPE,
|
|
1182
|
+
tool_name=MEMORY_TOOL_NAME,
|
|
1183
|
+
root_path=root_path,
|
|
1184
|
+
allowed_prefixes=allowed_prefixes or ["/memories"],
|
|
1185
|
+
max_file_size_mb=max_file_size_mb,
|
|
1186
|
+
system_prompt=system_prompt,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
__all__ = [
|
|
1191
|
+
"AnthropicToolsState",
|
|
1192
|
+
"FileData",
|
|
1193
|
+
"FilesystemClaudeMemoryMiddleware",
|
|
1194
|
+
"FilesystemClaudeTextEditorMiddleware",
|
|
1195
|
+
"StateClaudeMemoryMiddleware",
|
|
1196
|
+
"StateClaudeTextEditorMiddleware",
|
|
1197
|
+
]
|