agent-devkit 0.1.5 → 0.1.6

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 (87) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/runtime/README.md +74 -5
  4. package/runtime/agents/README.md +8 -0
  5. package/runtime/agents/execution-reviewer/AGENTS.md +8 -0
  6. package/runtime/agents/execution-reviewer/README.md +13 -0
  7. package/runtime/agents/execution-reviewer/agent.yaml +25 -0
  8. package/runtime/agents/execution-reviewer/capabilities/review-agent-result/capability.yaml +15 -0
  9. package/runtime/agents/execution-reviewer/capabilities/review-agent-result/decision-rules.md +6 -0
  10. package/runtime/agents/execution-reviewer/capabilities/review-agent-result/workflow.md +7 -0
  11. package/runtime/agents/execution-reviewer/capabilities/review-final-output/capability.yaml +15 -0
  12. package/runtime/agents/execution-reviewer/capabilities/review-final-output/decision-rules.md +6 -0
  13. package/runtime/agents/execution-reviewer/capabilities/review-final-output/workflow.md +7 -0
  14. package/runtime/agents/execution-reviewer/capabilities/review-plan/capability.yaml +15 -0
  15. package/runtime/agents/execution-reviewer/capabilities/review-plan/decision-rules.md +6 -0
  16. package/runtime/agents/execution-reviewer/capabilities/review-plan/workflow.md +7 -0
  17. package/runtime/agents/execution-reviewer/infra/README.md +4 -0
  18. package/runtime/agents/execution-reviewer/knowledge/context.md +6 -0
  19. package/runtime/agents/execution-reviewer/knowledge/system.md +18 -0
  20. package/runtime/agents/execution-reviewer/templates/README.md +4 -0
  21. package/runtime/agents/local-llm-operator/AGENTS.md +8 -0
  22. package/runtime/agents/local-llm-operator/README.md +12 -0
  23. package/runtime/agents/local-llm-operator/agent.yaml +26 -0
  24. package/runtime/agents/local-llm-operator/capabilities/delegate-operational-task/capability.yaml +15 -0
  25. package/runtime/agents/local-llm-operator/capabilities/delegate-operational-task/decision-rules.md +6 -0
  26. package/runtime/agents/local-llm-operator/capabilities/delegate-operational-task/workflow.md +7 -0
  27. package/runtime/agents/local-llm-operator/capabilities/inspect-local-models/capability.yaml +15 -0
  28. package/runtime/agents/local-llm-operator/capabilities/inspect-local-models/decision-rules.md +5 -0
  29. package/runtime/agents/local-llm-operator/capabilities/inspect-local-models/workflow.md +6 -0
  30. package/runtime/agents/local-llm-operator/capabilities/select-local-worker/capability.yaml +15 -0
  31. package/runtime/agents/local-llm-operator/capabilities/select-local-worker/decision-rules.md +6 -0
  32. package/runtime/agents/local-llm-operator/capabilities/select-local-worker/workflow.md +7 -0
  33. package/runtime/agents/local-llm-operator/infra/README.md +4 -0
  34. package/runtime/agents/local-llm-operator/knowledge/context.md +6 -0
  35. package/runtime/agents/local-llm-operator/knowledge/system.md +18 -0
  36. package/runtime/agents/local-llm-operator/templates/README.md +4 -0
  37. package/runtime/agents/provider-configurator/AGENTS.md +8 -0
  38. package/runtime/agents/provider-configurator/README.md +5 -0
  39. package/runtime/agents/provider-configurator/agent.yaml +25 -0
  40. package/runtime/agents/provider-configurator/capabilities/collect-provider-credentials/capability.yaml +15 -0
  41. package/runtime/agents/provider-configurator/capabilities/collect-provider-credentials/decision-rules.md +6 -0
  42. package/runtime/agents/provider-configurator/capabilities/collect-provider-credentials/workflow.md +7 -0
  43. package/runtime/agents/provider-configurator/capabilities/configure-provider-source/capability.yaml +15 -0
  44. package/runtime/agents/provider-configurator/capabilities/configure-provider-source/decision-rules.md +6 -0
  45. package/runtime/agents/provider-configurator/capabilities/configure-provider-source/workflow.md +8 -0
  46. package/runtime/agents/provider-configurator/capabilities/validate-provider-readiness/capability.yaml +15 -0
  47. package/runtime/agents/provider-configurator/capabilities/validate-provider-readiness/decision-rules.md +6 -0
  48. package/runtime/agents/provider-configurator/capabilities/validate-provider-readiness/workflow.md +7 -0
  49. package/runtime/agents/provider-configurator/infra/README.md +5 -0
  50. package/runtime/agents/provider-configurator/knowledge/context.md +8 -0
  51. package/runtime/agents/provider-configurator/knowledge/system.md +19 -0
  52. package/runtime/agents/provider-configurator/templates/README.md +4 -0
  53. package/runtime/agents/task-orchestrator/AGENTS.md +8 -0
  54. package/runtime/agents/task-orchestrator/README.md +7 -0
  55. package/runtime/agents/task-orchestrator/agent.yaml +31 -0
  56. package/runtime/agents/task-orchestrator/capabilities/coordinate-execution/capability.yaml +15 -0
  57. package/runtime/agents/task-orchestrator/capabilities/coordinate-execution/decision-rules.md +6 -0
  58. package/runtime/agents/task-orchestrator/capabilities/coordinate-execution/workflow.md +7 -0
  59. package/runtime/agents/task-orchestrator/capabilities/plan-task/capability.yaml +15 -0
  60. package/runtime/agents/task-orchestrator/capabilities/plan-task/decision-rules.md +6 -0
  61. package/runtime/agents/task-orchestrator/capabilities/plan-task/workflow.md +8 -0
  62. package/runtime/agents/task-orchestrator/capabilities/select-specialists/capability.yaml +15 -0
  63. package/runtime/agents/task-orchestrator/capabilities/select-specialists/decision-rules.md +6 -0
  64. package/runtime/agents/task-orchestrator/capabilities/select-specialists/workflow.md +7 -0
  65. package/runtime/agents/task-orchestrator/infra/README.md +5 -0
  66. package/runtime/agents/task-orchestrator/knowledge/context.md +9 -0
  67. package/runtime/agents/task-orchestrator/knowledge/system.md +20 -0
  68. package/runtime/agents/task-orchestrator/templates/README.md +4 -0
  69. package/runtime/cli/README.md +76 -11
  70. package/runtime/cli/aikit/__init__.py +1 -1
  71. package/runtime/cli/aikit/agent_executor.py +66 -0
  72. package/runtime/cli/aikit/agent_registry.py +80 -0
  73. package/runtime/cli/aikit/app_home.py +87 -2
  74. package/runtime/cli/aikit/audit.py +44 -0
  75. package/runtime/cli/aikit/control_router.py +298 -0
  76. package/runtime/cli/aikit/decision_store.py +17 -0
  77. package/runtime/cli/aikit/diagnostics.py +2 -1
  78. package/runtime/cli/aikit/execution_reviewer.py +174 -0
  79. package/runtime/cli/aikit/install.py +4 -3
  80. package/runtime/cli/aikit/local_llm_operator.py +112 -0
  81. package/runtime/cli/aikit/lock.py +4 -1
  82. package/runtime/cli/aikit/main.py +355 -80
  83. package/runtime/cli/aikit/model_router.py +13 -4
  84. package/runtime/cli/aikit/orchestrator.py +535 -0
  85. package/runtime/cli/aikit/sessions.py +37 -0
  86. package/runtime/cli/aikit/sources.py +6 -2
  87. package/runtime/cli/aikit/wizard_state.py +503 -0
@@ -0,0 +1,503 @@
1
+ """Persistent agentic setup wizard state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cli.aikit.app_home import app_path, ensure_app_home
13
+ from cli.aikit.decision_store import set_decision
14
+ from cli.aikit.memory import redact_secrets
15
+ from cli.aikit.sources import SourceRegistryError, add_source
16
+
17
+
18
+ YES_VALUES = {"s", "sim", "y", "yes", "true", "1", "ok"}
19
+ NO_VALUES = {"n", "nao", "não", "no", "false", "0"}
20
+ ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
21
+ SOURCE_ID_PATTERN = re.compile(r"[^a-z0-9._-]+")
22
+
23
+
24
+ class WizardStateError(ValueError):
25
+ """Raised for user-facing wizard state errors."""
26
+
27
+
28
+ def wizard_home() -> Path:
29
+ ensure_app_home()
30
+ path = app_path("state", "wizards")
31
+ path.mkdir(parents=True, exist_ok=True)
32
+ return path
33
+
34
+
35
+ def wizard_path(wizard_id: str) -> Path:
36
+ validate_wizard_id(wizard_id)
37
+ return wizard_home() / f"{wizard_id}.json"
38
+
39
+
40
+ def create_provider_wizard(
41
+ setup_wizard: dict[str, Any],
42
+ *,
43
+ execution_plan: dict[str, Any] | None = None,
44
+ route: dict[str, Any] | None = None,
45
+ ) -> dict[str, Any]:
46
+ """Persist a provider setup wizard and return its public representation."""
47
+ now = now_iso()
48
+ wizard_id = f"wiz-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}"
49
+ provider = str(setup_wizard.get("provider") or "provider")
50
+ safe_setup = sanitize_setup_wizard(setup_wizard, wizard_id=wizard_id)
51
+ first_question = safe_setup.get("next_question")
52
+ pending_questions: list[dict[str, Any]] = []
53
+ if isinstance(first_question, dict):
54
+ pending_questions.append(dict(first_question))
55
+ pending_questions.extend(dict(item) for item in safe_setup.get("questions") or [] if isinstance(item, dict))
56
+ status = "denied-by-user" if safe_setup.get("status") == "denied-by-user" else "waiting-for-user"
57
+ state = {
58
+ "kind": "provider-setup-wizard-state",
59
+ "schema_version": "ai-devkit.provider-wizard-state/v1",
60
+ "id": wizard_id,
61
+ "status": status,
62
+ "provider": provider,
63
+ "created_at": now,
64
+ "updated_at": now,
65
+ "setup_wizard": safe_setup,
66
+ "execution_plan": sanitize_execution_plan(execution_plan),
67
+ "route": sanitize_route(route),
68
+ "pending_questions": pending_questions,
69
+ "answered_questions": [],
70
+ "answers": {},
71
+ "source_result": None,
72
+ "stored_secret": False,
73
+ }
74
+ save_state(state)
75
+ return public_wizard(state)
76
+
77
+
78
+ def list_wizards(*, status: str | None = None) -> dict[str, Any]:
79
+ items = [public_wizard(load_state(path.stem)) for path in wizard_home().glob("*.json")]
80
+ if status:
81
+ items = [item for item in items if item.get("status") == status]
82
+ items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
83
+ return {
84
+ "kind": "wizards",
85
+ "status": "ok",
86
+ "items": items,
87
+ "home": str(wizard_home()),
88
+ "stored_secret": False,
89
+ }
90
+
91
+
92
+ def show_wizard(wizard_id: str) -> dict[str, Any]:
93
+ return {
94
+ "kind": "wizard",
95
+ "status": "ok",
96
+ "wizard": public_wizard(load_state(wizard_id)),
97
+ "stored_secret": False,
98
+ }
99
+
100
+
101
+ def cancel_wizard(wizard_id: str, *, reason: str | None = None) -> dict[str, Any]:
102
+ state = load_state(wizard_id)
103
+ state["status"] = "cancelled"
104
+ state["updated_at"] = now_iso()
105
+ state["cancel_reason"] = reason or "cancelled by user"
106
+ state["pending_questions"] = []
107
+ save_state(state)
108
+ return {
109
+ "kind": "wizard",
110
+ "status": "cancelled",
111
+ "wizard": public_wizard(state),
112
+ "stored_secret": False,
113
+ }
114
+
115
+
116
+ def answer_wizard(wizard_id: str, answer: str) -> dict[str, Any]:
117
+ state = load_state(wizard_id)
118
+ if state.get("status") in {"completed", "cancelled", "denied-by-user"}:
119
+ return {
120
+ "kind": "wizard",
121
+ "status": str(state.get("status")),
122
+ "wizard": public_wizard(state),
123
+ "stored_secret": False,
124
+ }
125
+ question = current_question(state)
126
+ if not question:
127
+ return complete_wizard(state)
128
+
129
+ parsed = parse_answer(question, answer)
130
+ if is_opt_in_question(state, question):
131
+ if not parsed:
132
+ provider = str(state.get("provider") or "")
133
+ set_decision("tools", provider, "denied_by_user", reason="agentic provider setup wizard opt-out")
134
+ state["status"] = "denied-by-user"
135
+ state["updated_at"] = now_iso()
136
+ state["pending_questions"] = []
137
+ append_answer(state, question, parsed)
138
+ save_state(state)
139
+ return {
140
+ "kind": "wizard",
141
+ "status": "denied-by-user",
142
+ "wizard": public_wizard(state),
143
+ "stored_secret": False,
144
+ }
145
+ pop_current_question(state)
146
+ append_answer(state, question, parsed)
147
+ state["status"] = "collecting-input"
148
+ state["updated_at"] = now_iso()
149
+ save_state(state)
150
+ return {
151
+ "kind": "wizard",
152
+ "status": "needs-input",
153
+ "wizard": public_wizard(state),
154
+ "next_question": current_question(state),
155
+ "stored_secret": False,
156
+ }
157
+
158
+ if is_skip_answer(question, parsed):
159
+ provider = str(state.get("provider") or "")
160
+ set_decision("tools", provider, "denied_by_user", reason="agentic provider setup wizard skipped")
161
+ state["status"] = "denied-by-user"
162
+ state["updated_at"] = now_iso()
163
+ state["pending_questions"] = []
164
+ append_answer(state, question, parsed)
165
+ save_state(state)
166
+ return {
167
+ "kind": "wizard",
168
+ "status": "denied-by-user",
169
+ "wizard": public_wizard(state),
170
+ "stored_secret": False,
171
+ }
172
+
173
+ additions = dynamic_questions_for_answer(state, question, parsed)
174
+ pop_current_question(state)
175
+ if additions:
176
+ state["pending_questions"] = additions + list(state.get("pending_questions") or [])
177
+ append_answer(state, question, parsed)
178
+ state["updated_at"] = now_iso()
179
+ if current_question(state):
180
+ save_state(state)
181
+ return {
182
+ "kind": "wizard",
183
+ "status": "needs-input",
184
+ "wizard": public_wizard(state),
185
+ "next_question": current_question(state),
186
+ "stored_secret": False,
187
+ }
188
+ return complete_wizard(state)
189
+
190
+
191
+ def complete_wizard(state: dict[str, Any]) -> dict[str, Any]:
192
+ if state.get("source_result"):
193
+ state["status"] = "completed"
194
+ state["updated_at"] = now_iso()
195
+ save_state(state)
196
+ return {"kind": "wizard", "status": "completed", "wizard": public_wizard(state), "stored_secret": False}
197
+ try:
198
+ source_result = create_source_from_wizard(state)
199
+ except SourceRegistryError as exc:
200
+ state["status"] = "failed"
201
+ state["updated_at"] = now_iso()
202
+ state["error"] = str(exc)
203
+ save_state(state)
204
+ return {
205
+ "kind": "wizard",
206
+ "status": "failed",
207
+ "wizard": public_wizard(state),
208
+ "error": str(exc),
209
+ "stored_secret": False,
210
+ }
211
+ state["status"] = "completed"
212
+ state["updated_at"] = now_iso()
213
+ state["source_result"] = source_result
214
+ save_state(state)
215
+ return {
216
+ "kind": "wizard",
217
+ "status": "completed",
218
+ "wizard": public_wizard(state),
219
+ "source_result": source_result,
220
+ "resume_prompt": resume_prompt(state),
221
+ "stored_secret": False,
222
+ }
223
+
224
+
225
+ def create_source_from_wizard(state: dict[str, Any]) -> dict[str, Any]:
226
+ provider = str(state.get("provider") or "")
227
+ answers = answer_map(state)
228
+ source_id = str(answers.get("source_id") or suggested_source_id(state)).strip()
229
+ source_id = normalize_source_id(source_id or provider)
230
+ intent = str((state.get("setup_wizard") or {}).get("intent") or "").strip()
231
+ agent_id = str((state.get("setup_wizard") or {}).get("agent_id") or "").strip()
232
+ config_pairs: list[str] = []
233
+ env_refs: list[str] = []
234
+ env_files: list[str] = []
235
+
236
+ for record in state.get("answered_questions") or []:
237
+ question = record.get("question") if isinstance(record, dict) else None
238
+ if not isinstance(question, dict):
239
+ continue
240
+ value = record.get("answer")
241
+ if value in {None, ""}:
242
+ continue
243
+ if question.get("config_key") and not question.get("secret"):
244
+ config_pairs.append(f"{question['config_key']}={value}")
245
+ if question.get("env_ref_key"):
246
+ env_key = str(question["env_ref_key"])
247
+ env_value = str(value)
248
+ if not ENV_VAR_NAME_PATTERN.fullmatch(env_value):
249
+ raise SourceRegistryError("credential answers must be environment variable names, not raw secret values")
250
+ if redact_secrets(env_value) != env_value:
251
+ raise SourceRegistryError("credential answers must not contain raw secret values")
252
+ env_refs.append(f"{env_key}={env_value}")
253
+ if question.get("env_file"):
254
+ env_files.append(str(value))
255
+
256
+ default_for: list[str] = []
257
+ default_for_agent: list[str] = []
258
+ if answers.get("default_for_intent") is True and intent:
259
+ default_for.append(intent)
260
+ if agent_id:
261
+ default_for_agent.append(agent_id)
262
+
263
+ return add_source(
264
+ source_id,
265
+ provider=provider,
266
+ label=source_id,
267
+ config_pairs=config_pairs,
268
+ env_refs=env_refs,
269
+ env_files=env_files,
270
+ default_for=default_for,
271
+ default_for_agent=default_for_agent,
272
+ set_default=True,
273
+ )
274
+
275
+
276
+ def current_question(state: dict[str, Any]) -> dict[str, Any] | None:
277
+ questions = state.get("pending_questions") if isinstance(state.get("pending_questions"), list) else []
278
+ if not questions:
279
+ return None
280
+ question = questions[0]
281
+ return dict(question) if isinstance(question, dict) else None
282
+
283
+
284
+ def pop_current_question(state: dict[str, Any]) -> None:
285
+ questions = state.get("pending_questions") if isinstance(state.get("pending_questions"), list) else []
286
+ state["pending_questions"] = list(questions[1:])
287
+
288
+
289
+ def append_answer(state: dict[str, Any], question: dict[str, Any], answer: Any) -> None:
290
+ state.setdefault("answered_questions", []).append(
291
+ {
292
+ "question": dict(question),
293
+ "answer": public_answer(question, answer),
294
+ "answered_at": now_iso(),
295
+ }
296
+ )
297
+ state.setdefault("answers", {})[str(question.get("id") or len(state["answered_questions"]))] = public_answer(question, answer)
298
+
299
+
300
+ def parse_answer(question: dict[str, Any], answer: str) -> Any:
301
+ raw = " ".join(str(answer or "").split())
302
+ if not raw and question.get("default") is not None:
303
+ return question.get("default")
304
+ if not raw and question.get("suggested_value"):
305
+ return question.get("suggested_value")
306
+ if question.get("env_ref_key"):
307
+ if not ENV_VAR_NAME_PATTERN.fullmatch(raw):
308
+ raise WizardStateError("informe o nome de uma variavel de ambiente, nao o valor bruto da credencial")
309
+ if redact_secrets(raw) != raw:
310
+ raise WizardStateError("informe uma referencia segura de credencial, nao o valor bruto do segredo")
311
+ return raw
312
+ if question.get("type") == "confirm":
313
+ lowered = raw.lower()
314
+ if lowered in YES_VALUES:
315
+ return True
316
+ if lowered in NO_VALUES:
317
+ return False
318
+ raise WizardStateError("responda sim ou nao")
319
+ if question.get("type") == "select":
320
+ options = [str(item) for item in question.get("options") or []]
321
+ if raw not in options:
322
+ raise WizardStateError(f"resposta invalida. opcoes: {', '.join(options)}")
323
+ return raw
324
+ if not raw and question.get("required"):
325
+ raise WizardStateError("resposta obrigatoria")
326
+ return raw
327
+
328
+
329
+ def dynamic_questions_for_answer(state: dict[str, Any], question: dict[str, Any], answer: Any) -> list[dict[str, Any]]:
330
+ if question.get("type") != "select" or not str(question.get("id") or "").endswith("_auth"):
331
+ return []
332
+ provider = str(state.get("provider") or "provider")
333
+ setup = state.get("setup_wizard") if isinstance(state.get("setup_wizard"), dict) else {}
334
+ auth_methods = setup.get("auth_methods") if isinstance(setup.get("auth_methods"), list) else []
335
+ selected = str(answer)
336
+ if selected == "file":
337
+ return [
338
+ {
339
+ "id": f"{provider_slug(provider)}_credential_file",
340
+ "type": "text",
341
+ "text": "Qual e o caminho do arquivo local com a credencial?",
342
+ "env_file": True,
343
+ "required": True,
344
+ "secret": False,
345
+ }
346
+ ]
347
+ if selected in {"env"} | {str(method.get("id")) for method in auth_methods if isinstance(method, dict)}:
348
+ secret_fields: list[str] = []
349
+ for method in auth_methods:
350
+ if not isinstance(method, dict):
351
+ continue
352
+ if selected != "env" and method.get("id") != selected:
353
+ continue
354
+ secret_fields.extend(str(item) for item in method.get("secret_fields") or [])
355
+ if selected == "env" and not secret_fields:
356
+ for option in setup.get("credential_options") or []:
357
+ if isinstance(option, dict) and option.get("env"):
358
+ secret_fields.append(str(option["env"]))
359
+ unique = []
360
+ for field in secret_fields:
361
+ if field and field not in unique:
362
+ unique.append(field)
363
+ return [
364
+ {
365
+ "id": f"{provider_slug(provider)}_{provider_slug(field)}_env_ref",
366
+ "type": "text",
367
+ "text": f"Qual variavel de ambiente contem {field}?",
368
+ "env_ref_key": field,
369
+ "required": True,
370
+ "secret": True,
371
+ "stores_secret": False,
372
+ "suggested_value": field,
373
+ }
374
+ for field in unique
375
+ ]
376
+ return []
377
+
378
+
379
+ def is_opt_in_question(state: dict[str, Any], question: dict[str, Any]) -> bool:
380
+ expected = ((state.get("setup_wizard") or {}).get("next_question") or {}).get("id") if isinstance(state.get("setup_wizard"), dict) else None
381
+ return bool(expected and question.get("id") == expected)
382
+
383
+
384
+ def is_skip_answer(question: dict[str, Any], answer: Any) -> bool:
385
+ return question.get("type") == "select" and str(answer) == "skip"
386
+
387
+
388
+ def answer_map(state: dict[str, Any]) -> dict[str, Any]:
389
+ result: dict[str, Any] = {}
390
+ for record in state.get("answered_questions") or []:
391
+ if not isinstance(record, dict):
392
+ continue
393
+ question = record.get("question")
394
+ if isinstance(question, dict):
395
+ result[str(question.get("id"))] = record.get("answer")
396
+ return result
397
+
398
+
399
+ def public_answer(question: dict[str, Any], answer: Any) -> Any:
400
+ if question.get("env_ref_key"):
401
+ return str(answer)
402
+ if question.get("secret") and not question.get("env_ref_key"):
403
+ return "[REDACTED]"
404
+ return answer
405
+
406
+
407
+ def public_wizard(state: dict[str, Any]) -> dict[str, Any]:
408
+ setup = state.get("setup_wizard") if isinstance(state.get("setup_wizard"), dict) else {}
409
+ question = current_question(state)
410
+ wizard = dict(setup)
411
+ wizard.update(
412
+ {
413
+ "wizard_id": state.get("id"),
414
+ "status": state.get("status"),
415
+ "provider": state.get("provider") or setup.get("provider"),
416
+ "state_path": str(wizard_path(str(state.get("id")))),
417
+ "created_at": state.get("created_at"),
418
+ "updated_at": state.get("updated_at"),
419
+ "answered_count": len(state.get("answered_questions") or []),
420
+ "pending_count": len(state.get("pending_questions") or []),
421
+ "next_question": question,
422
+ "resume_prompt": resume_prompt(state),
423
+ "stored_secret": False,
424
+ }
425
+ )
426
+ if state.get("source_result"):
427
+ wizard["source"] = (state["source_result"].get("source") if isinstance(state["source_result"], dict) else None)
428
+ if state.get("error"):
429
+ wizard["error"] = state.get("error")
430
+ return wizard
431
+
432
+
433
+ def resume_prompt(state: dict[str, Any]) -> str | None:
434
+ setup = state.get("setup_wizard") if isinstance(state.get("setup_wizard"), dict) else {}
435
+ prompt = setup.get("resume_prompt")
436
+ return str(prompt) if prompt else None
437
+
438
+
439
+ def suggested_source_id(state: dict[str, Any]) -> str:
440
+ setup = state.get("setup_wizard") if isinstance(state.get("setup_wizard"), dict) else {}
441
+ return str(setup.get("suggested_source_id") or state.get("provider") or "source")
442
+
443
+
444
+ def sanitize_setup_wizard(setup_wizard: dict[str, Any], *, wizard_id: str) -> dict[str, Any]:
445
+ safe = json.loads(json.dumps(setup_wizard, ensure_ascii=False))
446
+ if safe.get("resume_prompt"):
447
+ safe["resume_prompt"] = redact_secrets(str(safe["resume_prompt"]))
448
+ safe["wizard_id"] = wizard_id
449
+ safe["state_path"] = str(wizard_path(wizard_id))
450
+ safe["stored_secret"] = False
451
+ return safe
452
+
453
+
454
+ def sanitize_execution_plan(execution_plan: dict[str, Any] | None) -> dict[str, Any] | None:
455
+ if not execution_plan:
456
+ return None
457
+ safe = json.loads(json.dumps(execution_plan, ensure_ascii=False))
458
+ if safe.get("prompt"):
459
+ safe["prompt"] = redact_secrets(str(safe["prompt"]))
460
+ return safe
461
+
462
+
463
+ def sanitize_route(route: dict[str, Any] | None) -> dict[str, Any] | None:
464
+ if not route:
465
+ return None
466
+ return json.loads(json.dumps(route, ensure_ascii=False))
467
+
468
+
469
+ def save_state(state: dict[str, Any]) -> Path:
470
+ path = wizard_path(str(state["id"]))
471
+ path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
472
+ return path
473
+
474
+
475
+ def load_state(wizard_id: str) -> dict[str, Any]:
476
+ path = wizard_path(wizard_id)
477
+ if not path.exists():
478
+ raise WizardStateError(f"wizard not found: {wizard_id}")
479
+ try:
480
+ data = json.loads(path.read_text(encoding="utf-8"))
481
+ except json.JSONDecodeError as exc:
482
+ raise WizardStateError(f"invalid wizard state: {wizard_id}") from exc
483
+ if not isinstance(data, dict):
484
+ raise WizardStateError(f"invalid wizard state: {wizard_id}")
485
+ return data
486
+
487
+
488
+ def normalize_source_id(value: str) -> str:
489
+ normalized = SOURCE_ID_PATTERN.sub("-", value.lower()).strip("-")
490
+ return normalized or "source"
491
+
492
+
493
+ def validate_wizard_id(wizard_id: str) -> None:
494
+ if not re.fullmatch(r"wiz-[0-9]{14}-[a-f0-9]{8}", str(wizard_id or "")):
495
+ raise WizardStateError("invalid wizard id")
496
+
497
+
498
+ def provider_slug(provider: str) -> str:
499
+ return re.sub(r"[^a-z0-9]+", "_", provider.lower()).strip("_") or "provider"
500
+
501
+
502
+ def now_iso() -> str:
503
+ return datetime.now(timezone.utc).isoformat()