pcli-agent 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.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,77 @@
1
+ """ask_user_question: lets the model pause a turn to ask the user something
2
+ directly — a genuine ambiguity it can't safely resolve itself, or a choice
3
+ between approaches only they can make. Mirrors opencode's "ask" tool.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
9
+
10
+ ASK_USER_QUESTION_TOOL_NAME = "ask_user_question"
11
+
12
+
13
+ async def _ask_user_question(arguments: dict, ctx: ToolContext) -> ToolResult:
14
+ if ctx.ask_question is None:
15
+ return ToolResult(
16
+ output="No UI available to ask the user a question in this context.\n"
17
+ "[pcli] Suggestion: state your assumption plainly instead and proceed with it, "
18
+ "rather than blocking on an answer you can't get here.",
19
+ is_error=True,
20
+ )
21
+
22
+ question = arguments["question"]
23
+ options = arguments.get("options")
24
+
25
+ # When this fires from inside a subagent, the user otherwise sees a bare
26
+ # question with zero context - the subagent's own intermediate work never
27
+ # enters the main conversation (see tools/builtin/subagent_tool.py), so
28
+ # there's nothing else to relate the question to. Prepend a short summary
29
+ # of what the subagent has been doing so far, directly in the question
30
+ # text itself (rather than a separate UI channel), so it reaches whatever
31
+ # implements ask_question - the TUI modal today, anything else later.
32
+ activity = ctx.activity
33
+ sub = activity.subagent if activity is not None else None
34
+ asked_question = question
35
+ if sub is not None and activity is not None:
36
+ activity.set_subagent_pending_question(question, options)
37
+ recent = ", ".join(c.name for c in sub.call_log[-5:]) or "none yet"
38
+ asked_question = (
39
+ f"[This question is from a subagent working on: \"{sub.task}\" — "
40
+ f"{len(sub.call_log)} tool call(s) so far, most recent: {recent}. "
41
+ "Use /subagent for the full detail.]\n\n" + question
42
+ )
43
+
44
+ try:
45
+ answer = await ctx.ask_question(asked_question, options)
46
+ finally:
47
+ if sub is not None and activity is not None:
48
+ activity.clear_subagent_pending_question()
49
+ return ToolResult(output=answer)
50
+
51
+
52
+ ASK_USER_QUESTION = ToolSpec(
53
+ name=ASK_USER_QUESTION_TOOL_NAME,
54
+ description="Ask the user a direct question and wait for their answer — for a genuine "
55
+ "ambiguity you can't safely resolve yourself, or a choice between approaches only they can "
56
+ "make. Optionally provide a short list of suggested answers as 'options'; the user can "
57
+ "still type a free-text answer regardless of whether options are given. Don't reach for "
58
+ "this for something you could reasonably infer, verify yourself, or state as an assumption "
59
+ "and proceed — see the system prompt's guidance on when asking is actually warranted.",
60
+ parameters={
61
+ "type": "object",
62
+ "properties": {
63
+ "question": {"type": "string", "description": "The question to ask the user."},
64
+ "options": {
65
+ "type": "array",
66
+ "items": {"type": "string"},
67
+ "description": "Optional: a short list of suggested answers, shown as quick "
68
+ "choices. The user can still type something else.",
69
+ },
70
+ },
71
+ "required": ["question"],
72
+ },
73
+ handler=_ask_user_question,
74
+ needs_permission=False,
75
+ plan_mode_safe=True,
76
+ read_only=True,
77
+ )
@@ -0,0 +1,253 @@
1
+ """browser_*: drives a real Chromium browser (via Playwright, an optional
2
+ dependency - see pyproject.toml's "browser" extra and docs) so pcli can
3
+ operate a website the way a person would: click, type, wait for something
4
+ to appear, read what's on the page, see it via a screenshot. Distinct from
5
+ web_fetch/web_search (web_tools.py), which are HTTP-level and can't run
6
+ JavaScript, stay logged in, or interact with a page - reach for these only
7
+ when a site actually needs driving, not just reading.
8
+
9
+ Every call in a session shares one BrowserSession (ctx.browser_session, set
10
+ up once per AgentRuntime - see agent/runtime.py and browser/session.py) -
11
+ navigating, then clicking, then reading all happen in the same tab/login
12
+ state, the way a real browser session would.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pcli.browser.session import BrowserUnavailableError
18
+ from pcli.config.paths import browser_screenshots_dir
19
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
20
+ from pcli.util.ids import new_id
21
+
22
+ _INSTALL_SUGGESTION = (
23
+ '\n[pcli] Suggestion: install the optional browser extra and its browser binary once - '
24
+ '`pip install -e ".[browser]"` then `playwright install chromium` - then try again.'
25
+ )
26
+
27
+
28
+ def _no_session_result() -> ToolResult:
29
+ return ToolResult(
30
+ output="Browser tools aren't available in this context (no browser session configured).",
31
+ is_error=True,
32
+ )
33
+
34
+
35
+ async def _browser_navigate(arguments: dict, ctx: ToolContext) -> ToolResult:
36
+ if ctx.browser_session is None:
37
+ return _no_session_result()
38
+ url = arguments["url"]
39
+ try:
40
+ landed_url = await ctx.browser_session.navigate(url)
41
+ except BrowserUnavailableError as exc:
42
+ return ToolResult(output=f"{exc}{_INSTALL_SUGGESTION}", is_error=True)
43
+ return ToolResult(output=f"Navigated to {landed_url}")
44
+
45
+
46
+ BROWSER_NAVIGATE = ToolSpec(
47
+ name="browser_navigate",
48
+ description="Navigate the browser to a URL. Starts the browser on first use (a real, "
49
+ "visible Chromium window in the TUI by default, or headless for a scheduled/unattended "
50
+ "run). Use this before browser_click/browser_type/etc. - they act on whatever page is "
51
+ "currently open.",
52
+ parameters={
53
+ "type": "object",
54
+ "properties": {"url": {"type": "string", "description": "URL to navigate to."}},
55
+ "required": ["url"],
56
+ },
57
+ handler=_browser_navigate,
58
+ needs_permission=True,
59
+ risk_description="Navigates the browser to a URL, which can trigger network requests, "
60
+ "JavaScript execution, and login-state changes on a real website.",
61
+ plan_mode_safe=False,
62
+ read_only=False,
63
+ )
64
+
65
+
66
+ async def _browser_click(arguments: dict, ctx: ToolContext) -> ToolResult:
67
+ if ctx.browser_session is None:
68
+ return _no_session_result()
69
+ selector = arguments["selector"]
70
+ try:
71
+ await ctx.browser_session.click(selector)
72
+ except BrowserUnavailableError as exc:
73
+ return ToolResult(output=f"{exc}{_INSTALL_SUGGESTION}", is_error=True)
74
+ return ToolResult(output=f"Clicked '{selector}'")
75
+
76
+
77
+ BROWSER_CLICK = ToolSpec(
78
+ name="browser_click",
79
+ description="Click an element on the currently open page, identified by a Playwright "
80
+ "locator (CSS selector, or `text=...` to match visible text).",
81
+ parameters={
82
+ "type": "object",
83
+ "properties": {
84
+ "selector": {
85
+ "type": "string",
86
+ "description": "Playwright locator for the element to click, e.g. "
87
+ "'button#submit' or 'text=Sign in'.",
88
+ }
89
+ },
90
+ "required": ["selector"],
91
+ },
92
+ handler=_browser_click,
93
+ needs_permission=True,
94
+ risk_description="Clicks an element on a real website - can submit a form, confirm a "
95
+ "purchase, or otherwise take real action.",
96
+ plan_mode_safe=False,
97
+ read_only=False,
98
+ )
99
+
100
+
101
+ async def _browser_type(arguments: dict, ctx: ToolContext) -> ToolResult:
102
+ if ctx.browser_session is None:
103
+ return _no_session_result()
104
+ selector = arguments["selector"]
105
+ text = arguments["text"]
106
+ try:
107
+ await ctx.browser_session.type_text(selector, text)
108
+ except BrowserUnavailableError as exc:
109
+ return ToolResult(output=f"{exc}{_INSTALL_SUGGESTION}", is_error=True)
110
+ return ToolResult(output=f"Typed into '{selector}'")
111
+
112
+
113
+ BROWSER_TYPE = ToolSpec(
114
+ name="browser_type",
115
+ description="Type text into a field on the currently open page (replaces whatever was "
116
+ "already in it), identified by a Playwright locator.",
117
+ parameters={
118
+ "type": "object",
119
+ "properties": {
120
+ "selector": {
121
+ "type": "string",
122
+ "description": "Playwright locator for the field to type into.",
123
+ },
124
+ "text": {"type": "string", "description": "Text to type into the field."},
125
+ },
126
+ "required": ["selector", "text"],
127
+ },
128
+ handler=_browser_type,
129
+ needs_permission=True,
130
+ risk_description="Types text into a field on a real website - may enter real data "
131
+ "(credentials, search queries, form input).",
132
+ plan_mode_safe=False,
133
+ read_only=False,
134
+ )
135
+
136
+
137
+ async def _browser_press_key(arguments: dict, ctx: ToolContext) -> ToolResult:
138
+ if ctx.browser_session is None:
139
+ return _no_session_result()
140
+ key = arguments["key"]
141
+ try:
142
+ await ctx.browser_session.press_key(key)
143
+ except BrowserUnavailableError as exc:
144
+ return ToolResult(output=f"{exc}{_INSTALL_SUGGESTION}", is_error=True)
145
+ return ToolResult(output=f"Pressed '{key}'")
146
+
147
+
148
+ BROWSER_PRESS_KEY = ToolSpec(
149
+ name="browser_press_key",
150
+ description="Send a single keypress to the currently open page (e.g. 'Enter', 'Tab', "
151
+ "'Escape') - useful for submitting a form after browser_type, or dismissing something.",
152
+ parameters={
153
+ "type": "object",
154
+ "properties": {
155
+ "key": {
156
+ "type": "string",
157
+ "description": "Key to press, in Playwright's key naming (e.g. 'Enter', 'Tab', "
158
+ "'ArrowDown').",
159
+ }
160
+ },
161
+ "required": ["key"],
162
+ },
163
+ handler=_browser_press_key,
164
+ needs_permission=True,
165
+ risk_description="Sends a keypress to a real website - can submit a form or trigger a "
166
+ "keyboard shortcut.",
167
+ plan_mode_safe=False,
168
+ read_only=False,
169
+ )
170
+
171
+
172
+ async def _browser_wait_for(arguments: dict, ctx: ToolContext) -> ToolResult:
173
+ if ctx.browser_session is None:
174
+ return _no_session_result()
175
+ selector = arguments["selector"]
176
+ timeout_s = float(arguments.get("timeout_s", 10))
177
+ try:
178
+ await ctx.browser_session.wait_for(selector, timeout_s)
179
+ except BrowserUnavailableError as exc:
180
+ return ToolResult(output=f"{exc}{_INSTALL_SUGGESTION}", is_error=True)
181
+ return ToolResult(output=f"'{selector}' appeared")
182
+
183
+
184
+ BROWSER_WAIT_FOR = ToolSpec(
185
+ name="browser_wait_for",
186
+ description="Wait for an element to appear on the currently open page before continuing - "
187
+ "use this after an action that loads content asynchronously, instead of guessing how long "
188
+ "to wait.",
189
+ parameters={
190
+ "type": "object",
191
+ "properties": {
192
+ "selector": {"type": "string", "description": "Playwright locator to wait for."},
193
+ "timeout_s": {
194
+ "type": "number",
195
+ "description": "Seconds to wait before giving up (default 10).",
196
+ },
197
+ },
198
+ "required": ["selector"],
199
+ },
200
+ handler=_browser_wait_for,
201
+ needs_permission=True,
202
+ risk_description="Part of a browser automation sequence on a real website - gated the same "
203
+ "as the actions it's waiting on behalf of.",
204
+ plan_mode_safe=False,
205
+ read_only=True,
206
+ )
207
+
208
+
209
+ async def _browser_read_page(arguments: dict, ctx: ToolContext) -> ToolResult:
210
+ if ctx.browser_session is None:
211
+ return _no_session_result()
212
+ try:
213
+ text = await ctx.browser_session.read_page()
214
+ except BrowserUnavailableError as exc:
215
+ return ToolResult(output=f"{exc}{_INSTALL_SUGGESTION}", is_error=True)
216
+ url = ctx.browser_session.current_url or "(unknown URL)"
217
+ return ToolResult(output=f"--- {url} ---\n{text}" if text else f"--- {url} ---\n(empty page)")
218
+
219
+
220
+ BROWSER_READ_PAGE = ToolSpec(
221
+ name="browser_read_page",
222
+ description="Read the visible text of the currently open page - use this to see what's on "
223
+ "the page after navigating or acting on it, the way you'd look at the screen yourself.",
224
+ parameters={"type": "object", "properties": {}},
225
+ handler=_browser_read_page,
226
+ needs_permission=False,
227
+ plan_mode_safe=True,
228
+ read_only=True,
229
+ )
230
+
231
+
232
+ async def _browser_screenshot(arguments: dict, ctx: ToolContext) -> ToolResult:
233
+ if ctx.browser_session is None:
234
+ return _no_session_result()
235
+ path = browser_screenshots_dir() / f"{new_id('shot_')}.png"
236
+ try:
237
+ await ctx.browser_session.screenshot(path)
238
+ except BrowserUnavailableError as exc:
239
+ return ToolResult(output=f"{exc}{_INSTALL_SUGGESTION}", is_error=True)
240
+ url = ctx.browser_session.current_url or "(unknown URL)"
241
+ return ToolResult(output=f"Screenshot of {url} saved to {path}")
242
+
243
+
244
+ BROWSER_SCREENSHOT = ToolSpec(
245
+ name="browser_screenshot",
246
+ description="Take a screenshot of the currently open page and save it to disk - use this "
247
+ "to show the user what the page actually looks like, not just its text.",
248
+ parameters={"type": "object", "properties": {}},
249
+ handler=_browser_screenshot,
250
+ needs_permission=False,
251
+ plan_mode_safe=True,
252
+ read_only=False,
253
+ )
@@ -0,0 +1,73 @@
1
+ """record_decision: lets the LLM log a consequential decision it made,
2
+ with its reasoning, to the session's append-only decision log. Unlike
3
+ write_todos (which replaces the whole list every call), each call here
4
+ *adds* one entry — a decision doesn't get mutated once made; a change of
5
+ mind is a new entry, not an edit of the old one. This builds a persistent,
6
+ exportable audit trail (Session.decisions) that survives resuming, export,
7
+ and import, same as everything else on the session."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from pcli.session.models import Decision
12
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
13
+
14
+
15
+ def render_decisions(decisions: list[Decision]) -> str:
16
+ """Real Markdown ("- " bullets, not "• ") - the one caller (the
17
+ "resuming with N recorded decision(s)" system message in chat.py)
18
+ renders this through rich.markdown.Markdown, which needs actual
19
+ list-item syntax or it collapses multiple "\\n"-joined lines into one
20
+ run-on paragraph."""
21
+ if not decisions:
22
+ return "No decisions recorded."
23
+ return "\n".join(f"- {d.decision}" for d in decisions)
24
+
25
+
26
+ async def _record_decision(arguments: dict, ctx: ToolContext) -> ToolResult:
27
+ if ctx.session is None:
28
+ return ToolResult(output="No session available to record decisions in.", is_error=True)
29
+
30
+ decision = arguments.get("decision")
31
+ rationale = arguments.get("rationale")
32
+ if not decision or not rationale:
33
+ return ToolResult(
34
+ output="Both 'decision' and 'rationale' are required.\n"
35
+ "[pcli] Suggestion: decision is what was decided, stated plainly; rationale is "
36
+ "why, including supporting evidence when there is any.",
37
+ is_error=True,
38
+ )
39
+
40
+ entry = Decision(decision=decision, rationale=rationale)
41
+ ctx.session.decisions.append(entry)
42
+ return ToolResult(output=f"Decision recorded: {decision}")
43
+
44
+
45
+ RECORD_DECISION = ToolSpec(
46
+ name="record_decision",
47
+ description="Log a consequential decision you made during this task, with your reasoning — "
48
+ "e.g. choosing one approach over another, a non-obvious tradeoff, or anything the user "
49
+ "might later want to understand 'why' about. Builds a persistent, exportable audit trail "
50
+ "for the session (each call adds one entry; it doesn't replace prior ones — a change of "
51
+ "mind is a new entry). Use it for consequential decisions, not routine tool calls — most "
52
+ "actions don't need this. Include the evidence behind the decision in the rationale when "
53
+ "there is any (a file:line, a command's output, a search result).",
54
+ parameters={
55
+ "type": "object",
56
+ "properties": {
57
+ "decision": {
58
+ "type": "string",
59
+ "description": "What was decided, stated plainly.",
60
+ },
61
+ "rationale": {
62
+ "type": "string",
63
+ "description": "Why — the reasoning behind it, including supporting evidence "
64
+ "when there is any.",
65
+ },
66
+ },
67
+ "required": ["decision", "rationale"],
68
+ },
69
+ handler=_record_decision,
70
+ needs_permission=False,
71
+ plan_mode_safe=True,
72
+ read_only=False,
73
+ )