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,680 @@
|
|
|
1
|
+
"""Human in the loop middleware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, cast
|
|
8
|
+
|
|
9
|
+
from coco_core.messages import AIMessage, ToolCall, ToolMessage
|
|
10
|
+
from coco_graph.config import get_config
|
|
11
|
+
from coco_graph.prebuilt.tool_node import ToolRuntime
|
|
12
|
+
from coco_graph.types import Command, interrupt
|
|
13
|
+
from typing_extensions import NotRequired, TypedDict
|
|
14
|
+
|
|
15
|
+
from coco.agents.middleware.types import (
|
|
16
|
+
AgentMiddleware,
|
|
17
|
+
AgentState,
|
|
18
|
+
ContextT,
|
|
19
|
+
PrivateStateAttr,
|
|
20
|
+
ResponseT,
|
|
21
|
+
StateT,
|
|
22
|
+
ToolCallRequest,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
|
|
28
|
+
from coco_graph.runtime import Runtime
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_EDITED_TOOL_CALLS_KEY = "hitl_edited_tool_calls"
|
|
32
|
+
"""State key mapping tool call ID to the reviewer's replacement for it."""
|
|
33
|
+
|
|
34
|
+
_EDIT_NOTICE = (
|
|
35
|
+
"Note: a human reviewer replaced this tool call before it ran. The call recorded in "
|
|
36
|
+
"your message is the one you produced, not the one that executed. This was "
|
|
37
|
+
"intentional and authorized. Do not re-issue your original call."
|
|
38
|
+
)
|
|
39
|
+
"""Default text prepended to the result of a tool call a reviewer edited."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Action(TypedDict):
|
|
43
|
+
"""Represents an action with a name and args."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
"""The type or name of action being requested (e.g., `'add_numbers'`)."""
|
|
47
|
+
|
|
48
|
+
args: dict[str, Any]
|
|
49
|
+
"""Key-value pairs of args needed for the action (e.g., `{"a": 1, "b": 2}`)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ActionRequest(TypedDict):
|
|
53
|
+
"""Represents an action request with a name, args, and description."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
"""The name of the action being requested."""
|
|
57
|
+
|
|
58
|
+
args: dict[str, Any]
|
|
59
|
+
"""Key-value pairs of args needed for the action (e.g., `{"a": 1, "b": 2}`)."""
|
|
60
|
+
|
|
61
|
+
description: NotRequired[str]
|
|
62
|
+
"""The description of the action to be reviewed."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
DecisionType = Literal["approve", "edit", "reject", "respond"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ReviewConfig(TypedDict):
|
|
69
|
+
"""Policy for reviewing a HITL request."""
|
|
70
|
+
|
|
71
|
+
action_name: str
|
|
72
|
+
"""Name of the action associated with this review configuration."""
|
|
73
|
+
|
|
74
|
+
allowed_decisions: list[DecisionType]
|
|
75
|
+
"""The decisions that are allowed for this request."""
|
|
76
|
+
|
|
77
|
+
args_schema: NotRequired[dict[str, Any]]
|
|
78
|
+
"""JSON schema for the args associated with the action, if edits are allowed."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HITLRequest(TypedDict):
|
|
82
|
+
"""Request for human feedback on a sequence of actions requested by a model."""
|
|
83
|
+
|
|
84
|
+
action_requests: list[ActionRequest]
|
|
85
|
+
"""A list of agent actions for human review."""
|
|
86
|
+
|
|
87
|
+
review_configs: list[ReviewConfig]
|
|
88
|
+
"""Review configuration for all possible actions."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ApproveDecision(TypedDict):
|
|
92
|
+
"""Response when a human approves the action."""
|
|
93
|
+
|
|
94
|
+
type: Literal["approve"]
|
|
95
|
+
"""The type of response when a human approves the action."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class EditDecision(TypedDict):
|
|
99
|
+
"""Response when a human edits the action."""
|
|
100
|
+
|
|
101
|
+
type: Literal["edit"]
|
|
102
|
+
"""The type of response when a human edits the action."""
|
|
103
|
+
|
|
104
|
+
edited_action: Action
|
|
105
|
+
"""Edited action for the agent to perform.
|
|
106
|
+
|
|
107
|
+
Ex: for a tool call, a human reviewer can edit the tool name and args.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RejectDecision(TypedDict):
|
|
112
|
+
"""Response when a human rejects the action."""
|
|
113
|
+
|
|
114
|
+
type: Literal["reject"]
|
|
115
|
+
"""The type of response when a human rejects the action."""
|
|
116
|
+
|
|
117
|
+
message: NotRequired[str]
|
|
118
|
+
"""The human-provided reason for rejecting the action.
|
|
119
|
+
|
|
120
|
+
The reason is framed as a user rejection when sent to the model. If omitted,
|
|
121
|
+
the model is told that the tool was not executed and should not retry the same
|
|
122
|
+
tool call unless the user asks for it.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RespondDecision(TypedDict):
|
|
127
|
+
"""Response when a human answers on behalf of the tool, skipping execution.
|
|
128
|
+
|
|
129
|
+
Used for "ask user" style tools whose real implementation is the human's
|
|
130
|
+
response. The tool is not executed; instead, a synthetic `ToolMessage` with
|
|
131
|
+
`status="success"` and the provided `message` is returned to the model.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
type: Literal["respond"]
|
|
135
|
+
"""The type of response when a human responds on behalf of the tool."""
|
|
136
|
+
|
|
137
|
+
message: str
|
|
138
|
+
"""Content of the synthetic `ToolMessage` returned to the model."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
Decision = ApproveDecision | EditDecision | RejectDecision | RespondDecision
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class HITLResponse(TypedDict):
|
|
145
|
+
"""Response payload for a HITLRequest."""
|
|
146
|
+
|
|
147
|
+
decisions: list[Decision]
|
|
148
|
+
"""The decisions made by the human."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _DescriptionFactory(Protocol):
|
|
152
|
+
"""Callable that generates a description for a tool call."""
|
|
153
|
+
|
|
154
|
+
def __call__(
|
|
155
|
+
self, tool_call: ToolCall, state: AgentState[Any], runtime: Runtime[ContextT]
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Generate a description for a tool call."""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class InterruptOnConfig(TypedDict):
|
|
162
|
+
"""Configuration for an action requiring human in the loop.
|
|
163
|
+
|
|
164
|
+
This is the configuration format used in the `HumanInTheLoopMiddleware.__init__`
|
|
165
|
+
method.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
allowed_decisions: list[DecisionType]
|
|
169
|
+
"""The decisions that are allowed for this action."""
|
|
170
|
+
|
|
171
|
+
description: NotRequired[str | _DescriptionFactory]
|
|
172
|
+
"""The description attached to the request for human input.
|
|
173
|
+
|
|
174
|
+
Can be either:
|
|
175
|
+
|
|
176
|
+
- A static string describing the approval request
|
|
177
|
+
- A callable that dynamically generates the description based on agent state,
|
|
178
|
+
runtime, and tool call information
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
```python
|
|
182
|
+
# Static string description
|
|
183
|
+
config = InterruptOnConfig(
|
|
184
|
+
allowed_decisions=["approve", "reject"],
|
|
185
|
+
description="Please review this tool execution"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Dynamic callable description
|
|
189
|
+
def format_tool_description(
|
|
190
|
+
tool_call: ToolCall,
|
|
191
|
+
state: AgentState,
|
|
192
|
+
runtime: Runtime[ContextT]
|
|
193
|
+
) -> str:
|
|
194
|
+
import json
|
|
195
|
+
return (
|
|
196
|
+
f"Tool: {tool_call['name']}\\n"
|
|
197
|
+
f"Arguments:\\n{json.dumps(tool_call['args'], indent=2)}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
config = InterruptOnConfig(
|
|
201
|
+
allowed_decisions=["approve", "edit", "reject"],
|
|
202
|
+
description=format_tool_description
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
"""
|
|
206
|
+
args_schema: NotRequired[dict[str, Any]]
|
|
207
|
+
"""JSON schema for the args associated with the action, if edits are allowed."""
|
|
208
|
+
|
|
209
|
+
when: NotRequired[Callable[[ToolCallRequest], bool]]
|
|
210
|
+
"""Optional predicate controlling whether to interrupt for a given tool call.
|
|
211
|
+
|
|
212
|
+
Receives a `ToolCallRequest` and returns `True` to interrupt or `False` to
|
|
213
|
+
auto-approve. The predicate is called during `after_model` before the tool
|
|
214
|
+
call is added to the batched human-in-the-loop request.
|
|
215
|
+
|
|
216
|
+
The request is constructed with `tool=None` and a new `ToolRuntime`. The
|
|
217
|
+
`ToolRuntime` copies `context`, `store`, `stream_writer`, `execution_info`,
|
|
218
|
+
and `server_info` from the node-level `Runtime`, while `tool_call_id` is
|
|
219
|
+
populated from the current tool call. The `tools` argument is not supplied,
|
|
220
|
+
so it uses its default empty list.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
```python
|
|
224
|
+
# Only interrupt delete_file calls targeting /etc
|
|
225
|
+
config = InterruptOnConfig(
|
|
226
|
+
allowed_decisions=["approve", "reject"],
|
|
227
|
+
when=lambda req: req.tool_call["args"].get("path", "").startswith("/etc"),
|
|
228
|
+
)
|
|
229
|
+
```
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _HumanInTheLoopState(AgentState[ResponseT]):
|
|
234
|
+
"""State schema for `HumanInTheLoopMiddleware`."""
|
|
235
|
+
|
|
236
|
+
hitl_edited_tool_calls: NotRequired[Annotated[dict[str, Action], PrivateStateAttr]]
|
|
237
|
+
"""Track tool call edits from `after_model`, so they can be used by `wrap_tool_call`."""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
|
|
241
|
+
"""Human in the loop middleware."""
|
|
242
|
+
|
|
243
|
+
state_schema = _HumanInTheLoopState # type: ignore[assignment]
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
interrupt_on: dict[str, bool | InterruptOnConfig],
|
|
248
|
+
*,
|
|
249
|
+
description_prefix: str = "Tool execution requires approval",
|
|
250
|
+
edit_notice: str | None = _EDIT_NOTICE,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Initialize the human in the loop middleware.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
interrupt_on: Mapping of tool name to allowed actions.
|
|
256
|
+
|
|
257
|
+
If a tool doesn't have an entry, it's auto-approved by default.
|
|
258
|
+
|
|
259
|
+
* `True` indicates all decisions are allowed: approve, edit, reject,
|
|
260
|
+
and respond.
|
|
261
|
+
* `False` indicates that the tool is auto-approved.
|
|
262
|
+
* `InterruptOnConfig` indicates the specific decisions allowed for this
|
|
263
|
+
tool.
|
|
264
|
+
|
|
265
|
+
The `InterruptOnConfig` can include a `description` field (`str` or
|
|
266
|
+
`Callable`) for custom formatting of the interrupt description.
|
|
267
|
+
|
|
268
|
+
A `when` predicate can also be provided to dynamically control
|
|
269
|
+
whether a tool call triggers an interrupt.
|
|
270
|
+
description_prefix: The prefix to use when constructing action requests.
|
|
271
|
+
|
|
272
|
+
This is used to provide context about the tool call and the action being
|
|
273
|
+
requested.
|
|
274
|
+
|
|
275
|
+
Not used if a tool has a `description` in its `InterruptOnConfig`.
|
|
276
|
+
edit_notice: Text prepended to the result of a tool call a reviewer replaced
|
|
277
|
+
via an `edit` decision. Pass `None` to add nothing.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: If a tool's `InterruptOnConfig` does not have a non-empty
|
|
281
|
+
`allowed_decisions` list (e.g. a misspelled key or an empty list).
|
|
282
|
+
An interrupt config without decisions would otherwise be silently
|
|
283
|
+
dropped, disabling the approval gate for that tool.
|
|
284
|
+
"""
|
|
285
|
+
super().__init__()
|
|
286
|
+
self.edit_notice = edit_notice
|
|
287
|
+
resolved_configs: dict[str, InterruptOnConfig] = {}
|
|
288
|
+
for tool_name, tool_config in interrupt_on.items():
|
|
289
|
+
if isinstance(tool_config, bool):
|
|
290
|
+
if tool_config is True:
|
|
291
|
+
resolved_configs[tool_name] = InterruptOnConfig(
|
|
292
|
+
allowed_decisions=["approve", "edit", "reject", "respond"]
|
|
293
|
+
)
|
|
294
|
+
elif tool_config.get("allowed_decisions"):
|
|
295
|
+
resolved_configs[tool_name] = tool_config
|
|
296
|
+
else:
|
|
297
|
+
msg = (
|
|
298
|
+
f"Invalid `interrupt_on` config for tool '{tool_name}': "
|
|
299
|
+
"`allowed_decisions` must be a non-empty list of decision types "
|
|
300
|
+
"(e.g. ['approve', 'reject']). Got config with keys "
|
|
301
|
+
f"{sorted(tool_config.keys())} and "
|
|
302
|
+
f"allowed_decisions={tool_config.get('allowed_decisions')!r}."
|
|
303
|
+
)
|
|
304
|
+
raise ValueError(msg)
|
|
305
|
+
self.interrupt_on = resolved_configs
|
|
306
|
+
self.description_prefix = description_prefix
|
|
307
|
+
|
|
308
|
+
def _create_action_and_config(
|
|
309
|
+
self,
|
|
310
|
+
tool_call: ToolCall,
|
|
311
|
+
config: InterruptOnConfig,
|
|
312
|
+
state: AgentState[Any],
|
|
313
|
+
runtime: Runtime[ContextT],
|
|
314
|
+
) -> tuple[ActionRequest, ReviewConfig]:
|
|
315
|
+
"""Create an ActionRequest and ReviewConfig for a tool call."""
|
|
316
|
+
tool_name = tool_call["name"]
|
|
317
|
+
tool_args = tool_call["args"]
|
|
318
|
+
|
|
319
|
+
# Generate description using the description field (str or callable)
|
|
320
|
+
description_value = config.get("description")
|
|
321
|
+
if callable(description_value):
|
|
322
|
+
description = description_value(tool_call, state, runtime)
|
|
323
|
+
elif description_value is not None:
|
|
324
|
+
description = description_value
|
|
325
|
+
else:
|
|
326
|
+
description = f"{self.description_prefix}\n\nTool: {tool_name}\nArgs: {tool_args}"
|
|
327
|
+
|
|
328
|
+
# Create ActionRequest with description
|
|
329
|
+
action_request = ActionRequest(
|
|
330
|
+
name=tool_name,
|
|
331
|
+
args=tool_args,
|
|
332
|
+
description=description,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Create ReviewConfig
|
|
336
|
+
# eventually can get tool information and populate args_schema from there
|
|
337
|
+
review_config = ReviewConfig(
|
|
338
|
+
action_name=tool_name,
|
|
339
|
+
allowed_decisions=config["allowed_decisions"],
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return action_request, review_config
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _process_decision(
|
|
346
|
+
decision: Decision,
|
|
347
|
+
tool_call: ToolCall,
|
|
348
|
+
config: InterruptOnConfig,
|
|
349
|
+
) -> tuple[ToolCall | None, ToolMessage | None]:
|
|
350
|
+
"""Process a single decision and return the revised tool call and optional tool message."""
|
|
351
|
+
allowed_decisions = config["allowed_decisions"]
|
|
352
|
+
|
|
353
|
+
if decision["type"] == "approve" and "approve" in allowed_decisions:
|
|
354
|
+
return tool_call, None
|
|
355
|
+
if decision["type"] == "edit" and "edit" in allowed_decisions:
|
|
356
|
+
# Keep the model's own call in the message; `wrap_tool_call` substitutes the
|
|
357
|
+
# reviewer's at execution time.
|
|
358
|
+
return tool_call, None
|
|
359
|
+
if decision["type"] == "reject" and "reject" in allowed_decisions:
|
|
360
|
+
reason = decision.get("message")
|
|
361
|
+
content = (
|
|
362
|
+
f"User rejected the tool call for `{tool_call['name']}` with reason: {reason}"
|
|
363
|
+
if reason
|
|
364
|
+
else (
|
|
365
|
+
f"User rejected the tool call for `{tool_call['name']}` with id "
|
|
366
|
+
f"{tool_call['id']}. The tool was not executed. Do not retry this tool "
|
|
367
|
+
"call unless the user explicitly requests it."
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
tool_message = ToolMessage(
|
|
371
|
+
content=content,
|
|
372
|
+
name=tool_call["name"],
|
|
373
|
+
tool_call_id=tool_call["id"],
|
|
374
|
+
status="error",
|
|
375
|
+
)
|
|
376
|
+
return tool_call, tool_message
|
|
377
|
+
if decision["type"] == "respond" and "respond" in allowed_decisions:
|
|
378
|
+
# Skip tool execution; the human answers on behalf of the tool.
|
|
379
|
+
tool_message = ToolMessage(
|
|
380
|
+
content=decision["message"],
|
|
381
|
+
name=tool_call["name"],
|
|
382
|
+
tool_call_id=tool_call["id"],
|
|
383
|
+
status="success",
|
|
384
|
+
)
|
|
385
|
+
return tool_call, tool_message
|
|
386
|
+
msg = (
|
|
387
|
+
f"Unexpected human decision: {decision}. "
|
|
388
|
+
f"Decision type '{decision.get('type')}' "
|
|
389
|
+
f"is not allowed for tool '{tool_call['name']}'. "
|
|
390
|
+
f"Expected one of {allowed_decisions} based on the tool's configuration."
|
|
391
|
+
)
|
|
392
|
+
raise ValueError(msg)
|
|
393
|
+
|
|
394
|
+
def _should_interrupt(
|
|
395
|
+
self,
|
|
396
|
+
tool_call: ToolCall,
|
|
397
|
+
config: InterruptOnConfig,
|
|
398
|
+
state: AgentState[Any],
|
|
399
|
+
runtime: Runtime[ContextT],
|
|
400
|
+
) -> bool:
|
|
401
|
+
"""Return False if the `when` predicate rejects this tool call, True otherwise."""
|
|
402
|
+
when = config.get("when")
|
|
403
|
+
if when is None:
|
|
404
|
+
return True
|
|
405
|
+
try:
|
|
406
|
+
runnable_config = get_config()
|
|
407
|
+
except RuntimeError:
|
|
408
|
+
runnable_config = {}
|
|
409
|
+
tool_runtime = ToolRuntime(
|
|
410
|
+
state=state,
|
|
411
|
+
context=runtime.context,
|
|
412
|
+
config=runnable_config,
|
|
413
|
+
stream_writer=runtime.stream_writer,
|
|
414
|
+
tool_call_id=tool_call["id"],
|
|
415
|
+
store=runtime.store,
|
|
416
|
+
execution_info=runtime.execution_info,
|
|
417
|
+
server_info=runtime.server_info,
|
|
418
|
+
)
|
|
419
|
+
req = ToolCallRequest(
|
|
420
|
+
tool_call=tool_call,
|
|
421
|
+
tool=None,
|
|
422
|
+
state=state,
|
|
423
|
+
runtime=tool_runtime, # type: ignore[arg-type]
|
|
424
|
+
)
|
|
425
|
+
return when(req)
|
|
426
|
+
|
|
427
|
+
def after_model(
|
|
428
|
+
self, state: AgentState[Any], runtime: Runtime[ContextT]
|
|
429
|
+
) -> dict[str, Any] | None:
|
|
430
|
+
"""Trigger interrupt flows for relevant tool calls after an `AIMessage`.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
state: The current agent state.
|
|
434
|
+
runtime: The runtime context.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Updated message with the revised tool calls.
|
|
438
|
+
|
|
439
|
+
Raises:
|
|
440
|
+
ValueError: If the number of human decisions does not match the number of
|
|
441
|
+
interrupted tool calls.
|
|
442
|
+
"""
|
|
443
|
+
messages = state["messages"]
|
|
444
|
+
if not messages:
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
last_ai_msg = next((msg for msg in reversed(messages) if isinstance(msg, AIMessage)), None)
|
|
448
|
+
if not last_ai_msg or not last_ai_msg.tool_calls:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
# Create action requests and review configs for tools that need approval
|
|
452
|
+
action_requests: list[ActionRequest] = []
|
|
453
|
+
review_configs: list[ReviewConfig] = []
|
|
454
|
+
interrupt_indices: list[int] = []
|
|
455
|
+
|
|
456
|
+
for idx, tool_call in enumerate(last_ai_msg.tool_calls):
|
|
457
|
+
if (config := self.interrupt_on.get(tool_call["name"])) is not None:
|
|
458
|
+
if not self._should_interrupt(tool_call, config, state, runtime):
|
|
459
|
+
continue
|
|
460
|
+
action_request, review_config = self._create_action_and_config(
|
|
461
|
+
tool_call, config, state, runtime
|
|
462
|
+
)
|
|
463
|
+
action_requests.append(action_request)
|
|
464
|
+
review_configs.append(review_config)
|
|
465
|
+
interrupt_indices.append(idx)
|
|
466
|
+
|
|
467
|
+
# If no interrupts needed, return early, dropping any earlier turn's edits so
|
|
468
|
+
# they cannot be applied to this turn's tool calls.
|
|
469
|
+
if not action_requests:
|
|
470
|
+
if state.get(_EDITED_TOOL_CALLS_KEY):
|
|
471
|
+
return {_EDITED_TOOL_CALLS_KEY: {}}
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
# Create single HITLRequest with all actions and configs
|
|
475
|
+
hitl_request = HITLRequest(
|
|
476
|
+
action_requests=action_requests,
|
|
477
|
+
review_configs=review_configs,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Send interrupt and get response
|
|
481
|
+
decisions = interrupt(hitl_request)["decisions"]
|
|
482
|
+
|
|
483
|
+
# Validate that the number of decisions matches the number of interrupt tool calls
|
|
484
|
+
if (decisions_len := len(decisions)) != (interrupt_count := len(interrupt_indices)):
|
|
485
|
+
msg = (
|
|
486
|
+
f"Number of human decisions ({decisions_len}) does not match "
|
|
487
|
+
f"number of hanging tool calls ({interrupt_count})."
|
|
488
|
+
)
|
|
489
|
+
raise ValueError(msg)
|
|
490
|
+
|
|
491
|
+
# Process decisions and rebuild tool calls in original order
|
|
492
|
+
revised_tool_calls: list[ToolCall] = []
|
|
493
|
+
artificial_tool_messages: list[ToolMessage] = []
|
|
494
|
+
edited_tool_calls: dict[str, Action] = {}
|
|
495
|
+
decision_idx = 0
|
|
496
|
+
|
|
497
|
+
for idx, tool_call in enumerate(last_ai_msg.tool_calls):
|
|
498
|
+
if idx in interrupt_indices:
|
|
499
|
+
# This was an interrupt tool call - process the decision
|
|
500
|
+
config = self.interrupt_on[tool_call["name"]]
|
|
501
|
+
decision = decisions[decision_idx]
|
|
502
|
+
decision_idx += 1
|
|
503
|
+
|
|
504
|
+
revised_tool_call, tool_message = self._process_decision(
|
|
505
|
+
decision, tool_call, config
|
|
506
|
+
)
|
|
507
|
+
if revised_tool_call is not None:
|
|
508
|
+
revised_tool_calls.append(revised_tool_call)
|
|
509
|
+
if decision["type"] == "edit" and (edited_id := revised_tool_call.get("id")):
|
|
510
|
+
edited_tool_calls[edited_id] = decision["edited_action"]
|
|
511
|
+
if tool_message:
|
|
512
|
+
artificial_tool_messages.append(tool_message)
|
|
513
|
+
else:
|
|
514
|
+
# This was auto-approved - keep original
|
|
515
|
+
revised_tool_calls.append(tool_call)
|
|
516
|
+
|
|
517
|
+
# Update the AI message to only include approved tool calls
|
|
518
|
+
last_ai_msg.tool_calls = revised_tool_calls
|
|
519
|
+
|
|
520
|
+
# `wrap_tool_call` reads this back to substitute and annotate the call. Always
|
|
521
|
+
# written, so an earlier turn's edits cannot survive into this one.
|
|
522
|
+
return {
|
|
523
|
+
"messages": [last_ai_msg, *artificial_tool_messages],
|
|
524
|
+
_EDITED_TOOL_CALLS_KEY: edited_tool_calls,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async def aafter_model(
|
|
528
|
+
self, state: AgentState[Any], runtime: Runtime[ContextT]
|
|
529
|
+
) -> dict[str, Any] | None:
|
|
530
|
+
"""Async trigger interrupt flows for relevant tool calls after an `AIMessage`.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
state: The current agent state.
|
|
534
|
+
runtime: The runtime context.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Updated message with the revised tool calls.
|
|
538
|
+
"""
|
|
539
|
+
return self.after_model(state, runtime)
|
|
540
|
+
|
|
541
|
+
def _reviewer_edit(self, request: ToolCallRequest) -> Action | None:
|
|
542
|
+
"""The reviewer's replacement for this call, if an `edit` decision replaced it."""
|
|
543
|
+
tool_call_id = request.tool_call.get("id")
|
|
544
|
+
if not tool_call_id:
|
|
545
|
+
return None
|
|
546
|
+
edited = request.state.get(_EDITED_TOOL_CALLS_KEY) or {}
|
|
547
|
+
if tool_call_id in edited:
|
|
548
|
+
return cast("Action", edited[tool_call_id])
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
def _apply_edit(self, request: ToolCallRequest, executed: Action) -> ToolCallRequest:
|
|
552
|
+
"""Point the request at the reviewer's call, resolving a redirected tool.
|
|
553
|
+
|
|
554
|
+
Raises:
|
|
555
|
+
ValueError: If the reviewer named a tool the agent does not have.
|
|
556
|
+
"""
|
|
557
|
+
tool_call: ToolCall = {
|
|
558
|
+
**request.tool_call,
|
|
559
|
+
"name": executed["name"],
|
|
560
|
+
"args": executed["args"],
|
|
561
|
+
}
|
|
562
|
+
if executed["name"] == request.tool_call["name"]:
|
|
563
|
+
return request.override(tool_call=tool_call)
|
|
564
|
+
|
|
565
|
+
# `tool_call["name"]` and `tool` must stay in agreement.
|
|
566
|
+
available = request.runtime.tools
|
|
567
|
+
tool = next((t for t in available if t.name == executed["name"]), None)
|
|
568
|
+
if tool is None:
|
|
569
|
+
names = ", ".join(sorted(t.name for t in available))
|
|
570
|
+
msg = (
|
|
571
|
+
f"Reviewer edited tool call {request.tool_call['id']!r} to "
|
|
572
|
+
f"{executed['name']!r}, which is not an available tool. "
|
|
573
|
+
f"Available tools: {names}."
|
|
574
|
+
)
|
|
575
|
+
raise ValueError(msg)
|
|
576
|
+
return request.override(tool_call=tool_call, tool=tool)
|
|
577
|
+
|
|
578
|
+
def _notice(self, executed: Action, *, has_content: bool) -> str:
|
|
579
|
+
"""The notice text, stating the call that actually ran."""
|
|
580
|
+
notice = (
|
|
581
|
+
f"{self.edit_notice} Executed instead: {executed['name']} with arguments "
|
|
582
|
+
f"{json.dumps(executed['args'], default=str)}."
|
|
583
|
+
)
|
|
584
|
+
return f"{notice}\n\nTool response:" if has_content else notice
|
|
585
|
+
|
|
586
|
+
def _prepend_notice(self, message: ToolMessage, executed: Action) -> ToolMessage:
|
|
587
|
+
"""Return `message` with the reviewer-edit notice prepended to its content."""
|
|
588
|
+
if not self.edit_notice:
|
|
589
|
+
return message
|
|
590
|
+
edit_notice = self._notice(executed, has_content=bool(message.content))
|
|
591
|
+
|
|
592
|
+
content: str | list[str | dict[Any, Any]]
|
|
593
|
+
if isinstance(message.content, str):
|
|
594
|
+
if edit_notice in message.content:
|
|
595
|
+
return message
|
|
596
|
+
separator = "\n" if message.content else ""
|
|
597
|
+
content = f"{edit_notice}{separator}{message.content}"
|
|
598
|
+
else:
|
|
599
|
+
if any(edit_notice in str(block) for block in message.content):
|
|
600
|
+
return message
|
|
601
|
+
# Match the surrounding block shape; providers may reject mixed lists.
|
|
602
|
+
notice: str | dict[Any, Any] = (
|
|
603
|
+
edit_notice
|
|
604
|
+
if message.content and all(isinstance(b, str) for b in message.content)
|
|
605
|
+
else {"type": "text", "text": edit_notice}
|
|
606
|
+
)
|
|
607
|
+
content = [notice, *message.content]
|
|
608
|
+
|
|
609
|
+
return message.model_copy(update={"content": content})
|
|
610
|
+
|
|
611
|
+
def _annotate_edited_result(
|
|
612
|
+
self,
|
|
613
|
+
result: ToolMessage | Command[Any],
|
|
614
|
+
request: ToolCallRequest,
|
|
615
|
+
executed: Action | None,
|
|
616
|
+
) -> ToolMessage | Command[Any]:
|
|
617
|
+
"""Tell the model a reviewer replaced the call, and with what."""
|
|
618
|
+
if not self.edit_notice or executed is None:
|
|
619
|
+
return result
|
|
620
|
+
|
|
621
|
+
if isinstance(result, ToolMessage):
|
|
622
|
+
return self._prepend_notice(result, executed)
|
|
623
|
+
|
|
624
|
+
# A `Command` carries the `ToolMessage` in its state update.
|
|
625
|
+
if not isinstance(result, Command) or not isinstance(result.update, dict):
|
|
626
|
+
return result
|
|
627
|
+
messages = result.update.get("messages")
|
|
628
|
+
if not isinstance(messages, list):
|
|
629
|
+
return result
|
|
630
|
+
tool_call_id = request.tool_call.get("id")
|
|
631
|
+
return replace(
|
|
632
|
+
result,
|
|
633
|
+
update={
|
|
634
|
+
**result.update,
|
|
635
|
+
"messages": [
|
|
636
|
+
self._prepend_notice(message, executed)
|
|
637
|
+
if isinstance(message, ToolMessage) and message.tool_call_id == tool_call_id
|
|
638
|
+
else message
|
|
639
|
+
for message in messages
|
|
640
|
+
],
|
|
641
|
+
},
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def wrap_tool_call(
|
|
645
|
+
self,
|
|
646
|
+
request: ToolCallRequest,
|
|
647
|
+
handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
|
|
648
|
+
) -> ToolMessage | Command[Any]:
|
|
649
|
+
"""Prepend reviewer-edit guidance to the result of an edited tool call.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
request: The tool call request being executed.
|
|
653
|
+
handler: Callable that executes the tool.
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
The tool result, with a note prepended when a reviewer edited the call.
|
|
657
|
+
"""
|
|
658
|
+
executed = self._reviewer_edit(request)
|
|
659
|
+
if executed is not None:
|
|
660
|
+
request = self._apply_edit(request, executed)
|
|
661
|
+
return self._annotate_edited_result(handler(request), request, executed)
|
|
662
|
+
|
|
663
|
+
async def awrap_tool_call(
|
|
664
|
+
self,
|
|
665
|
+
request: ToolCallRequest,
|
|
666
|
+
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
|
667
|
+
) -> ToolMessage | Command[Any]:
|
|
668
|
+
"""Async variant of `wrap_tool_call`.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
request: The tool call request being executed.
|
|
672
|
+
handler: Awaitable callable that executes the tool.
|
|
673
|
+
|
|
674
|
+
Returns:
|
|
675
|
+
The tool result, with a note prepended when a reviewer edited the call.
|
|
676
|
+
"""
|
|
677
|
+
executed = self._reviewer_edit(request)
|
|
678
|
+
if executed is not None:
|
|
679
|
+
request = self._apply_edit(request, executed)
|
|
680
|
+
return self._annotate_edited_result(await handler(request), request, executed)
|