talkops-opscloud 0.3.0b1__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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
"""Typed interrupt and resume payload schemas for OpsCloud.
|
|
2
|
+
|
|
3
|
+
Provides deterministic, typed Pydantic models and TypedDicts for interrupt
|
|
4
|
+
requests and resumptions, aligned with official dcode contracts and terminal-native
|
|
5
|
+
(CLI and Textual TUI) operations.
|
|
6
|
+
|
|
7
|
+
Covers:
|
|
8
|
+
1. Tool Approvals (HITL): HITLRequest, ActionRequest, ReviewConfig, HitlDecision,
|
|
9
|
+
ApproveDecision, RejectDecision, EditDecision, SwitchManualDecision, AutoApproveAllDecision, HitlResumePayload.
|
|
10
|
+
2. Interactive Question Prompts: AskUserRequest, Question, Choice, ValidatedQuestion,
|
|
11
|
+
AskUserAnswered, AskUserCancelled, AskUserResumePayload, multi-select encoders/decoders,
|
|
12
|
+
and transcript rendering.
|
|
13
|
+
3. Goal & Acceptance Criteria Review: GoalReviewRequest, GoalReviewResumePayload.
|
|
14
|
+
4. Hook Invocation Interrupts: HookInvocationInterrupt, HookInvocationRequest, HookInvocationResponse.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from typing import (
|
|
21
|
+
Annotated,
|
|
22
|
+
Any,
|
|
23
|
+
Literal,
|
|
24
|
+
NotRequired,
|
|
25
|
+
Sequence,
|
|
26
|
+
assert_never,
|
|
27
|
+
get_args,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from pydantic import (
|
|
31
|
+
AfterValidator,
|
|
32
|
+
BaseModel,
|
|
33
|
+
ConfigDict,
|
|
34
|
+
Field,
|
|
35
|
+
TypeAdapter,
|
|
36
|
+
)
|
|
37
|
+
from typing_extensions import TypedDict
|
|
38
|
+
|
|
39
|
+
from opscloud.hooks.interrupt import (
|
|
40
|
+
HOOK_INVOCATION_INTERRUPT_TYPE,
|
|
41
|
+
HookInvocationInterrupt,
|
|
42
|
+
build_hook_interrupt_payload,
|
|
43
|
+
build_hook_resume_value,
|
|
44
|
+
is_hook_interrupt_payload,
|
|
45
|
+
parse_hook_interrupt_payload,
|
|
46
|
+
parse_hook_resume_value,
|
|
47
|
+
)
|
|
48
|
+
from opscloud.hooks.models.transport import (
|
|
49
|
+
HookInvocationRequest,
|
|
50
|
+
HookInvocationResponse,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
# 1. Normalization Vocabulary & Decision Tokens
|
|
55
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
_CONFIRM_DECISIONS: frozenset[str] = frozenset(
|
|
58
|
+
{
|
|
59
|
+
"accept",
|
|
60
|
+
"confirm",
|
|
61
|
+
"accepted",
|
|
62
|
+
"confirmed",
|
|
63
|
+
"approve",
|
|
64
|
+
"approved",
|
|
65
|
+
"approval",
|
|
66
|
+
"proceed",
|
|
67
|
+
"continue",
|
|
68
|
+
"y",
|
|
69
|
+
"yes",
|
|
70
|
+
"ok",
|
|
71
|
+
"true",
|
|
72
|
+
"1",
|
|
73
|
+
"goal_response",
|
|
74
|
+
"hitl_response",
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_SMART_DECISIONS: frozenset[str] = frozenset(
|
|
79
|
+
{
|
|
80
|
+
"smart_approve_all",
|
|
81
|
+
"enable_smart",
|
|
82
|
+
"smart",
|
|
83
|
+
"s",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_AUTO_DECISIONS: frozenset[str] = frozenset(
|
|
88
|
+
{
|
|
89
|
+
"auto_approve_all",
|
|
90
|
+
"enable_auto",
|
|
91
|
+
"auto",
|
|
92
|
+
"a",
|
|
93
|
+
"always",
|
|
94
|
+
"smart_approve_all",
|
|
95
|
+
"enable_smart",
|
|
96
|
+
"smart",
|
|
97
|
+
"s",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
_EDIT_DECISIONS: frozenset[str] = frozenset(
|
|
102
|
+
{
|
|
103
|
+
"edit",
|
|
104
|
+
"edited",
|
|
105
|
+
"e",
|
|
106
|
+
"modify",
|
|
107
|
+
"modified",
|
|
108
|
+
"update",
|
|
109
|
+
"updated",
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
_SWITCH_MANUAL_DECISIONS: frozenset[str] = frozenset(
|
|
114
|
+
{
|
|
115
|
+
"switch_manual",
|
|
116
|
+
"manual",
|
|
117
|
+
"switch",
|
|
118
|
+
"m",
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
_REJECT_DECISIONS: frozenset[str] = frozenset(
|
|
123
|
+
{
|
|
124
|
+
"reject",
|
|
125
|
+
"rejected",
|
|
126
|
+
"r",
|
|
127
|
+
"deny",
|
|
128
|
+
"denied",
|
|
129
|
+
"disapprove",
|
|
130
|
+
"disapproved",
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
_CANCEL_DECISIONS: frozenset[str] = frozenset(
|
|
135
|
+
{
|
|
136
|
+
"cancel",
|
|
137
|
+
"cancelled",
|
|
138
|
+
"dismiss",
|
|
139
|
+
"dismissed",
|
|
140
|
+
"close",
|
|
141
|
+
"closed",
|
|
142
|
+
"n",
|
|
143
|
+
"no",
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
# 2. HITL / Tool Approval Schemas
|
|
150
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
class ActionRequest(TypedDict, total=False):
|
|
153
|
+
"""An action request emitted when a tool call requires human review."""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
args: dict[str, Any]
|
|
157
|
+
id: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ReviewConfig(TypedDict, total=False):
|
|
161
|
+
"""Configuration governing review options for a specific action."""
|
|
162
|
+
|
|
163
|
+
action_name: str
|
|
164
|
+
allowed_decisions: list[str]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class HITLRequest(TypedDict, total=False):
|
|
168
|
+
"""LangGraph interrupt payload when sensitive tools require approval."""
|
|
169
|
+
|
|
170
|
+
action_requests: list[ActionRequest]
|
|
171
|
+
review_configs: list[ReviewConfig]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class HitlDecision(BaseModel):
|
|
175
|
+
"""Individual tool approval decision with support for args editing and manual switch."""
|
|
176
|
+
|
|
177
|
+
model_config = ConfigDict(extra="ignore")
|
|
178
|
+
|
|
179
|
+
type: Literal["approve", "reject", "edit", "switch_manual", "auto_approve_all", "smart_approve_all"] = "approve"
|
|
180
|
+
message: str | None = None
|
|
181
|
+
args: dict[str, Any] | None = None
|
|
182
|
+
|
|
183
|
+
def to_command_dict(self) -> dict[str, Any]:
|
|
184
|
+
"""Convert to dictionary matching LangGraph resume expectation."""
|
|
185
|
+
res: dict[str, Any] = {"type": self.type}
|
|
186
|
+
if self.message is not None:
|
|
187
|
+
res["message"] = self.message
|
|
188
|
+
if self.args is not None:
|
|
189
|
+
res["args"] = self.args
|
|
190
|
+
return res
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ApproveDecision(BaseModel):
|
|
194
|
+
"""Explicit approve decision."""
|
|
195
|
+
|
|
196
|
+
type: Literal["approve"] = "approve"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class RejectDecision(BaseModel):
|
|
200
|
+
"""Explicit reject decision with optional reason."""
|
|
201
|
+
|
|
202
|
+
type: Literal["reject"] = "reject"
|
|
203
|
+
message: str | None = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class EditDecision(BaseModel):
|
|
207
|
+
"""Explicit edit decision with revised tool arguments."""
|
|
208
|
+
|
|
209
|
+
type: Literal["edit"] = "edit"
|
|
210
|
+
args: dict[str, Any] = Field(default_factory=dict)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SwitchManualDecision(BaseModel):
|
|
214
|
+
"""Switch from auto mode fallback to manual review."""
|
|
215
|
+
|
|
216
|
+
type: Literal["switch_manual"] = "switch_manual"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class AutoApproveAllDecision(BaseModel):
|
|
220
|
+
"""Enable auto-approval for all tools in session."""
|
|
221
|
+
|
|
222
|
+
type: Literal["auto_approve_all"] = "auto_approve_all"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class SmartApproveAllDecision(BaseModel):
|
|
226
|
+
"""Enable smart-approval (Jev protected) for all tools in session."""
|
|
227
|
+
|
|
228
|
+
type: Literal["smart_approve_all"] = "smart_approve_all"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class HitlResumePayload(BaseModel):
|
|
232
|
+
"""Resume payload for HITL tool approval interrupts."""
|
|
233
|
+
|
|
234
|
+
decisions: list[HitlDecision] = Field(default_factory=list)
|
|
235
|
+
auto_approve_requested: bool = False
|
|
236
|
+
smart_approve_requested: bool = False
|
|
237
|
+
|
|
238
|
+
def to_command_value(self) -> dict[str, Any]:
|
|
239
|
+
"""Serialize for LangGraph `Command(resume=...)`."""
|
|
240
|
+
return {
|
|
241
|
+
"decisions": [d.to_command_dict() for d in self.decisions]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_raw(
|
|
246
|
+
cls,
|
|
247
|
+
data: Any,
|
|
248
|
+
count: int = 1,
|
|
249
|
+
) -> HitlResumePayload:
|
|
250
|
+
"""Parse arbitrary client data (dict, list, string) into HitlResumePayload.
|
|
251
|
+
|
|
252
|
+
Normalizes terminal CLI options, TUI modals, and headless responses.
|
|
253
|
+
"""
|
|
254
|
+
if isinstance(data, cls):
|
|
255
|
+
return data
|
|
256
|
+
|
|
257
|
+
parsed = data
|
|
258
|
+
if isinstance(data, str) and data.strip():
|
|
259
|
+
trimmed = data.strip()
|
|
260
|
+
if (trimmed.startswith("{") and trimmed.endswith("}")) or (
|
|
261
|
+
trimmed.startswith("[") and trimmed.endswith("]")
|
|
262
|
+
):
|
|
263
|
+
try:
|
|
264
|
+
parsed = json.loads(trimmed)
|
|
265
|
+
except (json.JSONDecodeError, ValueError):
|
|
266
|
+
parsed = data
|
|
267
|
+
|
|
268
|
+
auto_mode = False
|
|
269
|
+
smart_mode = False
|
|
270
|
+
target_count = max(1, count)
|
|
271
|
+
|
|
272
|
+
if isinstance(parsed, dict):
|
|
273
|
+
# Check for decisions array from HITL response
|
|
274
|
+
decisions_raw = parsed.get("decisions")
|
|
275
|
+
if isinstance(decisions_raw, list) and decisions_raw:
|
|
276
|
+
decisions: list[HitlDecision] = []
|
|
277
|
+
for item in decisions_raw:
|
|
278
|
+
if isinstance(item, dict):
|
|
279
|
+
raw_t = (
|
|
280
|
+
str(
|
|
281
|
+
item.get("type") or item.get("decision") or "approve",
|
|
282
|
+
)
|
|
283
|
+
.lower()
|
|
284
|
+
.strip()
|
|
285
|
+
)
|
|
286
|
+
msg = item.get("message") or item.get("feedback")
|
|
287
|
+
edited_args = item.get("args") or item.get("edited_args") or item.get("parameters")
|
|
288
|
+
|
|
289
|
+
if raw_t in _SMART_DECISIONS:
|
|
290
|
+
smart_mode = True
|
|
291
|
+
auto_mode = True
|
|
292
|
+
dec_type: Literal["approve", "reject", "edit", "switch_manual", "auto_approve_all", "smart_approve_all"] = "approve"
|
|
293
|
+
elif raw_t in _AUTO_DECISIONS:
|
|
294
|
+
auto_mode = True
|
|
295
|
+
dec_type = "approve"
|
|
296
|
+
elif raw_t in _EDIT_DECISIONS:
|
|
297
|
+
dec_type = "edit"
|
|
298
|
+
elif raw_t in _SWITCH_MANUAL_DECISIONS:
|
|
299
|
+
dec_type = "switch_manual"
|
|
300
|
+
elif raw_t in _CONFIRM_DECISIONS:
|
|
301
|
+
dec_type = "approve"
|
|
302
|
+
else:
|
|
303
|
+
dec_type = "reject"
|
|
304
|
+
|
|
305
|
+
decisions.append(
|
|
306
|
+
HitlDecision(
|
|
307
|
+
type=dec_type,
|
|
308
|
+
message=str(msg) if msg else None,
|
|
309
|
+
args=edited_args if isinstance(edited_args, dict) else None,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
s = str(item).lower().strip()
|
|
314
|
+
if s in _SMART_DECISIONS:
|
|
315
|
+
smart_mode = True
|
|
316
|
+
auto_mode = True
|
|
317
|
+
dec_type = "approve"
|
|
318
|
+
elif s in _AUTO_DECISIONS:
|
|
319
|
+
auto_mode = True
|
|
320
|
+
dec_type = "approve"
|
|
321
|
+
elif s in _EDIT_DECISIONS:
|
|
322
|
+
dec_type = "edit"
|
|
323
|
+
elif s in _SWITCH_MANUAL_DECISIONS:
|
|
324
|
+
dec_type = "switch_manual"
|
|
325
|
+
elif s in _CONFIRM_DECISIONS:
|
|
326
|
+
dec_type = "approve"
|
|
327
|
+
else:
|
|
328
|
+
dec_type = "reject"
|
|
329
|
+
decisions.append(HitlDecision(type=dec_type))
|
|
330
|
+
|
|
331
|
+
if decisions:
|
|
332
|
+
return cls(
|
|
333
|
+
decisions=decisions,
|
|
334
|
+
auto_approve_requested=auto_mode,
|
|
335
|
+
smart_approve_requested=smart_mode,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Check single decision / action
|
|
339
|
+
raw_dec = (
|
|
340
|
+
parsed.get("decision")
|
|
341
|
+
or parsed.get("action")
|
|
342
|
+
or parsed.get("choice")
|
|
343
|
+
or parsed.get("type")
|
|
344
|
+
or parsed.get("status")
|
|
345
|
+
)
|
|
346
|
+
dec_str = str(raw_dec).lower().strip() if raw_dec is not None else ""
|
|
347
|
+
msg = parsed.get("message") or parsed.get("feedback") or parsed.get("rejectionReason")
|
|
348
|
+
msg_str = str(msg).strip() if msg else None
|
|
349
|
+
edited_args = parsed.get("args") or parsed.get("edited_args") or parsed.get("parameters")
|
|
350
|
+
|
|
351
|
+
if dec_str in _SMART_DECISIONS:
|
|
352
|
+
return cls(
|
|
353
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
354
|
+
auto_approve_requested=True,
|
|
355
|
+
smart_approve_requested=True,
|
|
356
|
+
)
|
|
357
|
+
if dec_str in _AUTO_DECISIONS:
|
|
358
|
+
return cls(
|
|
359
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
360
|
+
auto_approve_requested=True,
|
|
361
|
+
smart_approve_requested=False,
|
|
362
|
+
)
|
|
363
|
+
if dec_str in _EDIT_DECISIONS:
|
|
364
|
+
return cls(
|
|
365
|
+
decisions=[
|
|
366
|
+
HitlDecision(
|
|
367
|
+
type="edit",
|
|
368
|
+
message=msg_str,
|
|
369
|
+
args=edited_args if isinstance(edited_args, dict) else None,
|
|
370
|
+
)
|
|
371
|
+
for _ in range(target_count)
|
|
372
|
+
],
|
|
373
|
+
auto_approve_requested=False,
|
|
374
|
+
smart_approve_requested=False,
|
|
375
|
+
)
|
|
376
|
+
if dec_str in _SWITCH_MANUAL_DECISIONS:
|
|
377
|
+
return cls(
|
|
378
|
+
decisions=[HitlDecision(type="switch_manual") for _ in range(target_count)],
|
|
379
|
+
auto_approve_requested=False,
|
|
380
|
+
smart_approve_requested=False,
|
|
381
|
+
)
|
|
382
|
+
if dec_str in _CONFIRM_DECISIONS:
|
|
383
|
+
return cls(
|
|
384
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
385
|
+
auto_approve_requested=False,
|
|
386
|
+
smart_approve_requested=False,
|
|
387
|
+
)
|
|
388
|
+
if dec_str in _REJECT_DECISIONS or dec_str in _CANCEL_DECISIONS:
|
|
389
|
+
return cls(
|
|
390
|
+
decisions=[HitlDecision(type="reject", message=msg_str) for _ in range(target_count)],
|
|
391
|
+
auto_approve_requested=False,
|
|
392
|
+
smart_approve_requested=False,
|
|
393
|
+
)
|
|
394
|
+
return cls(
|
|
395
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
396
|
+
auto_approve_requested=False,
|
|
397
|
+
smart_approve_requested=False,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if isinstance(parsed, list):
|
|
401
|
+
decisions = []
|
|
402
|
+
for item in parsed:
|
|
403
|
+
s = str(item).lower().strip()
|
|
404
|
+
if s in _SMART_DECISIONS:
|
|
405
|
+
smart_mode = True
|
|
406
|
+
auto_mode = True
|
|
407
|
+
dec_type = "approve"
|
|
408
|
+
elif s in _AUTO_DECISIONS:
|
|
409
|
+
auto_mode = True
|
|
410
|
+
dec_type = "approve"
|
|
411
|
+
elif s in _EDIT_DECISIONS:
|
|
412
|
+
dec_type = "edit"
|
|
413
|
+
elif s in _SWITCH_MANUAL_DECISIONS:
|
|
414
|
+
dec_type = "switch_manual"
|
|
415
|
+
elif s in _CONFIRM_DECISIONS:
|
|
416
|
+
dec_type = "approve"
|
|
417
|
+
else:
|
|
418
|
+
dec_type = "reject"
|
|
419
|
+
decisions.append(HitlDecision(type=dec_type))
|
|
420
|
+
return cls(
|
|
421
|
+
decisions=decisions or [HitlDecision(type="approve")],
|
|
422
|
+
auto_approve_requested=auto_mode,
|
|
423
|
+
smart_approve_requested=smart_mode,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if isinstance(parsed, str):
|
|
427
|
+
s = parsed.lower().strip()
|
|
428
|
+
if s in _SMART_DECISIONS:
|
|
429
|
+
return cls(
|
|
430
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
431
|
+
auto_approve_requested=True,
|
|
432
|
+
smart_approve_requested=True,
|
|
433
|
+
)
|
|
434
|
+
if s in _AUTO_DECISIONS:
|
|
435
|
+
return cls(
|
|
436
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
437
|
+
auto_approve_requested=True,
|
|
438
|
+
smart_approve_requested=False,
|
|
439
|
+
)
|
|
440
|
+
if s in _EDIT_DECISIONS:
|
|
441
|
+
return cls(
|
|
442
|
+
decisions=[HitlDecision(type="edit") for _ in range(target_count)],
|
|
443
|
+
auto_approve_requested=False,
|
|
444
|
+
smart_approve_requested=False,
|
|
445
|
+
)
|
|
446
|
+
if s in _SWITCH_MANUAL_DECISIONS:
|
|
447
|
+
return cls(
|
|
448
|
+
decisions=[HitlDecision(type="switch_manual") for _ in range(target_count)],
|
|
449
|
+
auto_approve_requested=False,
|
|
450
|
+
smart_approve_requested=False,
|
|
451
|
+
)
|
|
452
|
+
if s in _CONFIRM_DECISIONS:
|
|
453
|
+
return cls(
|
|
454
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
455
|
+
auto_approve_requested=False,
|
|
456
|
+
smart_approve_requested=False,
|
|
457
|
+
)
|
|
458
|
+
if s in _REJECT_DECISIONS or s in _CANCEL_DECISIONS:
|
|
459
|
+
return cls(
|
|
460
|
+
decisions=[HitlDecision(type="reject") for _ in range(target_count)],
|
|
461
|
+
auto_approve_requested=False,
|
|
462
|
+
smart_approve_requested=False,
|
|
463
|
+
)
|
|
464
|
+
return cls(
|
|
465
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
466
|
+
auto_approve_requested=False,
|
|
467
|
+
smart_approve_requested=False,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
return cls(
|
|
471
|
+
decisions=[HitlDecision(type="approve") for _ in range(target_count)],
|
|
472
|
+
auto_approve_requested=False,
|
|
473
|
+
smart_approve_requested=False,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
478
|
+
# 3. Interactive Question Prompts (ask_user) Schemas & Encoders
|
|
479
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
QuestionType = Literal["text", "multiple_choice", "multi_select"]
|
|
482
|
+
"""Supported `ask_user` question types."""
|
|
483
|
+
|
|
484
|
+
QUESTION_TYPES: frozenset[str] = frozenset(get_args(QuestionType))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _requires_choices(question_type: QuestionType) -> bool:
|
|
488
|
+
"""Return whether question_type needs a non-empty choices list."""
|
|
489
|
+
if question_type == "text":
|
|
490
|
+
return False
|
|
491
|
+
if question_type == "multiple_choice" or question_type == "multi_select":
|
|
492
|
+
return True
|
|
493
|
+
assert_never(question_type)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
CHOICE_QUESTION_TYPES: frozenset[str] = frozenset(
|
|
497
|
+
qt for qt in get_args(QuestionType) if _requires_choices(qt)
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def encode_multi_select_answer(values: list[str]) -> str:
|
|
502
|
+
"""Encode selected values of a multi_select answer into a JSON string.
|
|
503
|
+
|
|
504
|
+
Keeps exactly one slot per question on the wire.
|
|
505
|
+
"""
|
|
506
|
+
return json.dumps(values, ensure_ascii=False)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def decode_multi_select_answer(raw: str) -> list[str] | None:
|
|
510
|
+
"""Decode a multi_select answer produced by encode_multi_select_answer."""
|
|
511
|
+
try:
|
|
512
|
+
decoded = json.loads(raw)
|
|
513
|
+
except (json.JSONDecodeError, RecursionError):
|
|
514
|
+
return None
|
|
515
|
+
if not isinstance(decoded, list) or not all(isinstance(v, str) for v in decoded):
|
|
516
|
+
return None
|
|
517
|
+
return decoded
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def ask_user_answer_is_empty(answer: str, question_type: object) -> bool:
|
|
521
|
+
"""Return whether an ask_user answer counts as empty/unanswered."""
|
|
522
|
+
if question_type == "multi_select":
|
|
523
|
+
return not decode_multi_select_answer(answer)
|
|
524
|
+
return not answer.strip()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _validate_question_text(text: str) -> str:
|
|
528
|
+
if not text.strip():
|
|
529
|
+
raise ValueError("question text must not be blank")
|
|
530
|
+
return text
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class Choice(TypedDict):
|
|
534
|
+
"""A single choice option for a multiple choice or multi-select question."""
|
|
535
|
+
|
|
536
|
+
value: Annotated[str, Field(description="The display label for this choice.")]
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _validate_choice(choice: Choice) -> Choice:
|
|
540
|
+
if not choice.get("value", "").strip():
|
|
541
|
+
raise ValueError(f"choice has a blank 'value': {choice!r}")
|
|
542
|
+
return choice
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _validate_question(question: Question) -> Question:
|
|
546
|
+
q_type = question.get("type", "text")
|
|
547
|
+
q_text = question.get("question", "")
|
|
548
|
+
choices = question.get("choices")
|
|
549
|
+
if q_type in CHOICE_QUESTION_TYPES:
|
|
550
|
+
if not choices:
|
|
551
|
+
raise ValueError(f"{q_type} question {q_text!r} requires a non-empty 'choices' list")
|
|
552
|
+
elif choices:
|
|
553
|
+
raise ValueError(f"{q_type} question {q_text!r} must not define 'choices'")
|
|
554
|
+
return question
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class Question(TypedDict):
|
|
558
|
+
"""A question to ask the user in terminal CLI or TUI."""
|
|
559
|
+
|
|
560
|
+
question: Annotated[
|
|
561
|
+
str,
|
|
562
|
+
AfterValidator(_validate_question_text),
|
|
563
|
+
Field(description="The question text to display.", min_length=1),
|
|
564
|
+
]
|
|
565
|
+
type: Annotated[
|
|
566
|
+
QuestionType,
|
|
567
|
+
Field(
|
|
568
|
+
description=(
|
|
569
|
+
"Question type. 'text' for free-form input, 'multiple_choice' for "
|
|
570
|
+
"picking exactly one option, 'multi_select' for picking one or more options."
|
|
571
|
+
)
|
|
572
|
+
),
|
|
573
|
+
]
|
|
574
|
+
choices: NotRequired[
|
|
575
|
+
Annotated[
|
|
576
|
+
list[Annotated[Choice, AfterValidator(_validate_choice)]],
|
|
577
|
+
Field(description="Options for multiple_choice and multi_select questions."),
|
|
578
|
+
]
|
|
579
|
+
]
|
|
580
|
+
required: NotRequired[
|
|
581
|
+
Annotated[
|
|
582
|
+
bool,
|
|
583
|
+
Field(description="Whether the user must answer. Defaults to true if omitted."),
|
|
584
|
+
]
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
ValidatedQuestion = Annotated[Question, AfterValidator(_validate_question)]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _validate_questions(questions: list[ValidatedQuestion]) -> list[ValidatedQuestion]:
|
|
592
|
+
if not questions:
|
|
593
|
+
raise ValueError("ask_user requires at least one question")
|
|
594
|
+
return questions
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class AskUserRequest(TypedDict):
|
|
598
|
+
"""Request payload sent via interrupt when asking the user questions."""
|
|
599
|
+
|
|
600
|
+
type: Literal["ask_user"]
|
|
601
|
+
questions: Annotated[list[ValidatedQuestion], AfterValidator(_validate_questions)]
|
|
602
|
+
tool_call_id: str
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
class AskUserAnswered(TypedDict):
|
|
606
|
+
"""Widget result when the user submits answers."""
|
|
607
|
+
|
|
608
|
+
type: Literal["answered"]
|
|
609
|
+
answers: list[str]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class AskUserCancelled(TypedDict):
|
|
613
|
+
"""Widget result when the user cancels the prompt."""
|
|
614
|
+
|
|
615
|
+
type: Literal["cancelled"]
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
AskUserWidgetResult = AskUserAnswered | AskUserCancelled
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
ASK_USER_NOTHING_SELECTED = "(nothing selected)"
|
|
622
|
+
ASK_USER_NO_ANSWER = "(no answer)"
|
|
623
|
+
ASK_USER_CANCELLED_ANSWER = "(cancelled)"
|
|
624
|
+
ASK_USER_ERROR_ANSWER_PREFIX = "(error: "
|
|
625
|
+
ASK_USER_ANSWERED_SUMMARY = "User answered"
|
|
626
|
+
ASK_USER_CANCELLED_SUMMARY = "Question cancelled"
|
|
627
|
+
ASK_USER_FAILED_SUMMARY = "Question failed"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def format_ask_user_error_answer(detail: str) -> str:
|
|
631
|
+
"""Render the placeholder answer recorded for every question on failure."""
|
|
632
|
+
return f"{ASK_USER_ERROR_ANSWER_PREFIX}{detail})"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def format_ask_user_transcript(
|
|
636
|
+
questions: Sequence[Question | dict[str, Any]],
|
|
637
|
+
answers: Sequence[str],
|
|
638
|
+
) -> str:
|
|
639
|
+
"""Render questions and answers as blank-line separated Q:/A: blocks."""
|
|
640
|
+
blocks = [
|
|
641
|
+
f"Q: {q.get('question', '')}\n"
|
|
642
|
+
f"A: {answers[i] if i < len(answers) else ASK_USER_NO_ANSWER}"
|
|
643
|
+
for i, q in enumerate(questions)
|
|
644
|
+
]
|
|
645
|
+
return "\n\n".join(blocks)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def render_ask_user_transcript_for_display(
|
|
649
|
+
questions: list[Question | dict[str, Any]],
|
|
650
|
+
transcript: str,
|
|
651
|
+
) -> str | None:
|
|
652
|
+
"""Re-render transcript with multi_select answers unpacked into human-readable lines."""
|
|
653
|
+
if not questions:
|
|
654
|
+
return None
|
|
655
|
+
anchors = [f"Q: {q.get('question', '')}\nA: " for q in questions]
|
|
656
|
+
answers: list[str] = []
|
|
657
|
+
position = 0
|
|
658
|
+
for index, anchor in enumerate(anchors):
|
|
659
|
+
if not transcript.startswith(anchor, position):
|
|
660
|
+
return None
|
|
661
|
+
position += len(anchor)
|
|
662
|
+
if index + 1 == len(anchors):
|
|
663
|
+
answers.append(transcript[position:])
|
|
664
|
+
break
|
|
665
|
+
separator = f"\n\n{anchors[index + 1]}"
|
|
666
|
+
end = transcript.find(separator, position)
|
|
667
|
+
if end == -1 or transcript.find(separator, end + 2) != -1:
|
|
668
|
+
return None
|
|
669
|
+
answers.append(transcript[position:end])
|
|
670
|
+
position = end + 2
|
|
671
|
+
|
|
672
|
+
changed = False
|
|
673
|
+
blocks: list[str] = []
|
|
674
|
+
for q, ans in zip(questions, answers, strict=True):
|
|
675
|
+
rendered = ans
|
|
676
|
+
if q.get("type") == "multi_select":
|
|
677
|
+
values = decode_multi_select_answer(ans)
|
|
678
|
+
if values is not None:
|
|
679
|
+
rendered = "\n".join(values) if values else ASK_USER_NOTHING_SELECTED
|
|
680
|
+
changed = changed or rendered != ans
|
|
681
|
+
blocks.append(f"Q: {q.get('question', '')}\nA: {rendered}")
|
|
682
|
+
if not changed:
|
|
683
|
+
return None
|
|
684
|
+
return "\n\n".join(blocks)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
class AskUserResumePayload(BaseModel):
|
|
688
|
+
"""Resume payload for ask_user question prompts."""
|
|
689
|
+
|
|
690
|
+
status: Literal["answered", "cancelled", "error"] = "answered"
|
|
691
|
+
answers: list[str] = Field(default_factory=list)
|
|
692
|
+
error: str | None = None
|
|
693
|
+
|
|
694
|
+
def to_command_value(self) -> dict[str, Any]:
|
|
695
|
+
"""Serialize for LangGraph `Command(resume=...)`."""
|
|
696
|
+
res: dict[str, Any] = {
|
|
697
|
+
"status": self.status,
|
|
698
|
+
"answers": self.answers,
|
|
699
|
+
}
|
|
700
|
+
if self.error:
|
|
701
|
+
res["error"] = self.error
|
|
702
|
+
return res
|
|
703
|
+
|
|
704
|
+
@classmethod
|
|
705
|
+
def from_raw(
|
|
706
|
+
cls,
|
|
707
|
+
data: Any,
|
|
708
|
+
questions: list[Any] | None = None,
|
|
709
|
+
) -> AskUserResumePayload:
|
|
710
|
+
"""Parse arbitrary client data into AskUserResumePayload."""
|
|
711
|
+
if isinstance(data, cls):
|
|
712
|
+
return data
|
|
713
|
+
|
|
714
|
+
q_count = len(questions) if questions else 1
|
|
715
|
+
parsed = data
|
|
716
|
+
if isinstance(data, str) and data.strip():
|
|
717
|
+
trimmed = data.strip()
|
|
718
|
+
if (trimmed.startswith("{") and trimmed.endswith("}")) or (
|
|
719
|
+
trimmed.startswith("[") and trimmed.endswith("]")
|
|
720
|
+
):
|
|
721
|
+
try:
|
|
722
|
+
parsed = json.loads(trimmed)
|
|
723
|
+
except (json.JSONDecodeError, ValueError):
|
|
724
|
+
parsed = data
|
|
725
|
+
|
|
726
|
+
if isinstance(parsed, dict):
|
|
727
|
+
status = str(parsed.get("status") or parsed.get("type", "answered")).lower().strip()
|
|
728
|
+
err_msg = parsed.get("error")
|
|
729
|
+
|
|
730
|
+
if status == "cancelled":
|
|
731
|
+
return cls(
|
|
732
|
+
status="cancelled",
|
|
733
|
+
answers=[ASK_USER_CANCELLED_ANSWER for _ in range(q_count)],
|
|
734
|
+
)
|
|
735
|
+
if status == "error":
|
|
736
|
+
return cls(
|
|
737
|
+
status="error",
|
|
738
|
+
error=str(err_msg) if err_msg else "unknown error",
|
|
739
|
+
answers=[format_ask_user_error_answer(str(err_msg or "error")) for _ in range(q_count)],
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if "answers" in parsed:
|
|
743
|
+
raw_ans = parsed["answers"]
|
|
744
|
+
ans_list = [str(x) for x in raw_ans] if isinstance(raw_ans, list) else [str(raw_ans)]
|
|
745
|
+
return cls(status="answered", answers=ans_list)
|
|
746
|
+
|
|
747
|
+
if "choice" in parsed:
|
|
748
|
+
return cls(status="answered", answers=[str(parsed["choice"])])
|
|
749
|
+
if "text" in parsed:
|
|
750
|
+
return cls(status="answered", answers=[str(parsed["text"])])
|
|
751
|
+
if "answer" in parsed:
|
|
752
|
+
return cls(status="answered", answers=[str(parsed["answer"])])
|
|
753
|
+
|
|
754
|
+
return cls(status="answered", answers=[json.dumps(parsed)])
|
|
755
|
+
|
|
756
|
+
if isinstance(parsed, list):
|
|
757
|
+
return cls(status="answered", answers=[str(x) for x in parsed])
|
|
758
|
+
|
|
759
|
+
if isinstance(parsed, str):
|
|
760
|
+
s = parsed.strip()
|
|
761
|
+
if s.lower() in ("cancel", "cancelled"):
|
|
762
|
+
return cls(
|
|
763
|
+
status="cancelled",
|
|
764
|
+
answers=[ASK_USER_CANCELLED_ANSWER for _ in range(q_count)],
|
|
765
|
+
)
|
|
766
|
+
return cls(status="answered", answers=[s])
|
|
767
|
+
|
|
768
|
+
return cls(
|
|
769
|
+
status="answered",
|
|
770
|
+
answers=[str(parsed) if parsed is not None else ""],
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
775
|
+
# 4. Goal, Criteria & Rubric Review Schemas
|
|
776
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
class GoalReviewRequest(TypedDict, total=False):
|
|
779
|
+
"""Request payload for human verification of proposed goals and criteria."""
|
|
780
|
+
|
|
781
|
+
type: Literal["goal_review"]
|
|
782
|
+
goal: str
|
|
783
|
+
criteria: list[str]
|
|
784
|
+
rubric: str | None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
class GoalReviewResumePayload(BaseModel):
|
|
788
|
+
"""Resume payload for goal_review / propose_goal interrupts."""
|
|
789
|
+
|
|
790
|
+
decision: Literal["confirm", "edit", "reject", "cancel"] = "confirm"
|
|
791
|
+
criteria: list[str] | None = None
|
|
792
|
+
feedback: str | None = None
|
|
793
|
+
|
|
794
|
+
def to_command_value(self) -> dict[str, Any]:
|
|
795
|
+
"""Serialize for LangGraph `Command(resume=...)`."""
|
|
796
|
+
res: dict[str, Any] = {"decision": self.decision}
|
|
797
|
+
if self.criteria is not None:
|
|
798
|
+
res["criteria"] = self.criteria
|
|
799
|
+
if self.feedback is not None:
|
|
800
|
+
res["feedback"] = self.feedback
|
|
801
|
+
return res
|
|
802
|
+
|
|
803
|
+
@classmethod
|
|
804
|
+
def from_raw(
|
|
805
|
+
cls,
|
|
806
|
+
data: Any,
|
|
807
|
+
) -> GoalReviewResumePayload:
|
|
808
|
+
"""Parse arbitrary client data into GoalReviewResumePayload."""
|
|
809
|
+
if isinstance(data, cls):
|
|
810
|
+
return data
|
|
811
|
+
|
|
812
|
+
parsed = data
|
|
813
|
+
if isinstance(data, str) and data.strip():
|
|
814
|
+
trimmed = data.strip()
|
|
815
|
+
if (trimmed.startswith("{") and trimmed.endswith("}")) or (
|
|
816
|
+
trimmed.startswith("[") and trimmed.endswith("]")
|
|
817
|
+
):
|
|
818
|
+
try:
|
|
819
|
+
parsed = json.loads(trimmed)
|
|
820
|
+
except (json.JSONDecodeError, ValueError):
|
|
821
|
+
parsed = data
|
|
822
|
+
|
|
823
|
+
if isinstance(parsed, dict):
|
|
824
|
+
# Unwrap nested dict if wrapped by interrupt ID
|
|
825
|
+
if len(parsed) == 1:
|
|
826
|
+
key = next(iter(parsed))
|
|
827
|
+
if key not in (
|
|
828
|
+
"decision",
|
|
829
|
+
"decisions",
|
|
830
|
+
"action",
|
|
831
|
+
"status",
|
|
832
|
+
"feedback",
|
|
833
|
+
"criteria",
|
|
834
|
+
"goalText",
|
|
835
|
+
"objective",
|
|
836
|
+
"choice",
|
|
837
|
+
"message",
|
|
838
|
+
"type",
|
|
839
|
+
):
|
|
840
|
+
inner = parsed[key]
|
|
841
|
+
if isinstance(inner, dict):
|
|
842
|
+
parsed = inner
|
|
843
|
+
elif isinstance(inner, str):
|
|
844
|
+
try:
|
|
845
|
+
parsed = json.loads(inner)
|
|
846
|
+
except (json.JSONDecodeError, ValueError):
|
|
847
|
+
parsed = {"decision": inner}
|
|
848
|
+
|
|
849
|
+
# Check decisions list from HITL
|
|
850
|
+
decisions_val = parsed.get("decisions")
|
|
851
|
+
answers_val = parsed.get("answers")
|
|
852
|
+
if isinstance(decisions_val, list) and decisions_val:
|
|
853
|
+
first = decisions_val[0]
|
|
854
|
+
if isinstance(first, dict):
|
|
855
|
+
raw_dec = first.get("type") or first.get("decision") or "confirm"
|
|
856
|
+
feedback = first.get("message") or first.get("feedback")
|
|
857
|
+
else:
|
|
858
|
+
raw_dec = str(first)
|
|
859
|
+
feedback = None
|
|
860
|
+
elif isinstance(answers_val, list) and answers_val:
|
|
861
|
+
raw_dec = answers_val[0]
|
|
862
|
+
feedback = None
|
|
863
|
+
else:
|
|
864
|
+
raw_dec = (
|
|
865
|
+
parsed.get("decision")
|
|
866
|
+
or parsed.get("action")
|
|
867
|
+
or parsed.get("choice")
|
|
868
|
+
or parsed.get("type")
|
|
869
|
+
or parsed.get("status")
|
|
870
|
+
or "confirm"
|
|
871
|
+
)
|
|
872
|
+
feedback = parsed.get("feedback") or parsed.get("rejectionReason") or parsed.get("message")
|
|
873
|
+
|
|
874
|
+
raw_str = str(raw_dec).lower().strip()
|
|
875
|
+
fb_str = str(feedback).strip() if feedback else None
|
|
876
|
+
|
|
877
|
+
# Extract criteria if present
|
|
878
|
+
criteria_list: list[str] | None = None
|
|
879
|
+
crit_val = parsed.get("criteria")
|
|
880
|
+
if isinstance(crit_val, list):
|
|
881
|
+
criteria_list = [str(x).strip() for x in crit_val if str(x).strip()]
|
|
882
|
+
elif isinstance(crit_val, str) and crit_val.strip():
|
|
883
|
+
criteria_list = [
|
|
884
|
+
line.strip().lstrip("-*•0123456789.) ").strip()
|
|
885
|
+
for line in crit_val.splitlines()
|
|
886
|
+
if line.strip().lstrip("-*•0123456789.) ").strip()
|
|
887
|
+
]
|
|
888
|
+
|
|
889
|
+
if raw_str in _EDIT_DECISIONS:
|
|
890
|
+
return cls(
|
|
891
|
+
decision="edit",
|
|
892
|
+
criteria=criteria_list,
|
|
893
|
+
feedback=fb_str,
|
|
894
|
+
)
|
|
895
|
+
if raw_str in _REJECT_DECISIONS or (fb_str and raw_str not in _CONFIRM_DECISIONS):
|
|
896
|
+
return cls(
|
|
897
|
+
decision="reject",
|
|
898
|
+
criteria=criteria_list,
|
|
899
|
+
feedback=fb_str,
|
|
900
|
+
)
|
|
901
|
+
if raw_str in _CANCEL_DECISIONS:
|
|
902
|
+
return cls(
|
|
903
|
+
decision="cancel",
|
|
904
|
+
criteria=criteria_list,
|
|
905
|
+
feedback=fb_str,
|
|
906
|
+
)
|
|
907
|
+
return cls(
|
|
908
|
+
decision="confirm",
|
|
909
|
+
criteria=criteria_list,
|
|
910
|
+
feedback=fb_str,
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
if isinstance(parsed, str):
|
|
914
|
+
s = parsed.lower().strip()
|
|
915
|
+
if s in _EDIT_DECISIONS:
|
|
916
|
+
return cls(decision="edit")
|
|
917
|
+
if s in _REJECT_DECISIONS:
|
|
918
|
+
return cls(decision="reject")
|
|
919
|
+
if s in _CANCEL_DECISIONS:
|
|
920
|
+
return cls(decision="cancel")
|
|
921
|
+
return cls(decision="confirm")
|
|
922
|
+
|
|
923
|
+
return cls(decision="confirm")
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
927
|
+
# 5. Export Definitions
|
|
928
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
929
|
+
|
|
930
|
+
__all__ = [
|
|
931
|
+
# HITL & Tool Approval
|
|
932
|
+
"ActionRequest",
|
|
933
|
+
"ApproveDecision",
|
|
934
|
+
"AutoApproveAllDecision",
|
|
935
|
+
"EditDecision",
|
|
936
|
+
"HITLRequest",
|
|
937
|
+
"HitlDecision",
|
|
938
|
+
"HitlResumePayload",
|
|
939
|
+
"RejectDecision",
|
|
940
|
+
"ReviewConfig",
|
|
941
|
+
"SmartApproveAllDecision",
|
|
942
|
+
"SwitchManualDecision",
|
|
943
|
+
# Ask User Protocol
|
|
944
|
+
"ASK_USER_ANSWERED_SUMMARY",
|
|
945
|
+
"ASK_USER_CANCELLED_ANSWER",
|
|
946
|
+
"ASK_USER_CANCELLED_SUMMARY",
|
|
947
|
+
"ASK_USER_ERROR_ANSWER_PREFIX",
|
|
948
|
+
"ASK_USER_FAILED_SUMMARY",
|
|
949
|
+
"ASK_USER_NOTHING_SELECTED",
|
|
950
|
+
"ASK_USER_NO_ANSWER",
|
|
951
|
+
"AskUserAnswered",
|
|
952
|
+
"AskUserCancelled",
|
|
953
|
+
"AskUserRequest",
|
|
954
|
+
"AskUserResumePayload",
|
|
955
|
+
"AskUserWidgetResult",
|
|
956
|
+
"CHOICE_QUESTION_TYPES",
|
|
957
|
+
"Choice",
|
|
958
|
+
"QUESTION_TYPES",
|
|
959
|
+
"Question",
|
|
960
|
+
"QuestionType",
|
|
961
|
+
"ValidatedQuestion",
|
|
962
|
+
"ask_user_answer_is_empty",
|
|
963
|
+
"decode_multi_select_answer",
|
|
964
|
+
"encode_multi_select_answer",
|
|
965
|
+
"format_ask_user_error_answer",
|
|
966
|
+
"format_ask_user_transcript",
|
|
967
|
+
"render_ask_user_transcript_for_display",
|
|
968
|
+
# Goal & Criteria Review
|
|
969
|
+
"GoalReviewRequest",
|
|
970
|
+
"GoalReviewResumePayload",
|
|
971
|
+
# Hook Invocation Interrupts
|
|
972
|
+
"HOOK_INVOCATION_INTERRUPT_TYPE",
|
|
973
|
+
"HookInvocationInterrupt",
|
|
974
|
+
"HookInvocationRequest",
|
|
975
|
+
"HookInvocationResponse",
|
|
976
|
+
"build_hook_interrupt_payload",
|
|
977
|
+
"build_hook_resume_value",
|
|
978
|
+
"is_hook_interrupt_payload",
|
|
979
|
+
"parse_hook_interrupt_payload",
|
|
980
|
+
"parse_hook_resume_value",
|
|
981
|
+
]
|