agent-devkit 0.0.3 → 0.1.0

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 (57) hide show
  1. package/README.md +170 -1
  2. package/package.json +1 -1
  3. package/runtime/README.md +192 -14
  4. package/runtime/agent +29 -13
  5. package/runtime/agents/README.md +3 -0
  6. package/runtime/agents/github-pr-reviewer/AGENTS.md +10 -0
  7. package/runtime/agents/github-pr-reviewer/README.md +7 -0
  8. package/runtime/agents/github-pr-reviewer/agent.yaml +33 -0
  9. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/capability.yaml +20 -0
  10. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/decision-rules.md +8 -0
  11. package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/workflow.md +9 -0
  12. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/capability.yaml +20 -0
  13. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/decision-rules.md +7 -0
  14. package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/workflow.md +8 -0
  15. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/capability.yaml +20 -0
  16. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/decision-rules.md +7 -0
  17. package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/workflow.md +8 -0
  18. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/capability.yaml +20 -0
  19. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/decision-rules.md +9 -0
  20. package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/workflow.md +10 -0
  21. package/runtime/agents/github-pr-reviewer/infra/README.md +7 -0
  22. package/runtime/agents/github-pr-reviewer/knowledge/context.md +7 -0
  23. package/runtime/agents/github-pr-reviewer/knowledge/system.md +17 -0
  24. package/runtime/agents/github-pr-reviewer/templates/pr-automation-output.md +10 -0
  25. package/runtime/agents/github-pr-reviewer/templates/pr-inspection-output.md +9 -0
  26. package/runtime/agents/github-pr-reviewer/templates/pr-list-output.md +9 -0
  27. package/runtime/agents/github-pr-reviewer/templates/pr-review-output.md +17 -0
  28. package/runtime/cli/README.md +93 -0
  29. package/runtime/cli/aikit/__init__.py +1 -1
  30. package/runtime/cli/aikit/aliases.py +196 -0
  31. package/runtime/cli/aikit/app_home.py +78 -0
  32. package/runtime/cli/aikit/audit.py +344 -0
  33. package/runtime/cli/aikit/calendar.py +163 -0
  34. package/runtime/cli/aikit/diagnostics.py +9 -0
  35. package/runtime/cli/aikit/github_pr.py +254 -0
  36. package/runtime/cli/aikit/identity.py +75 -0
  37. package/runtime/cli/aikit/llm.py +240 -48
  38. package/runtime/cli/aikit/main.py +924 -24
  39. package/runtime/cli/aikit/memory.py +148 -8
  40. package/runtime/cli/aikit/notifications.py +9 -0
  41. package/runtime/cli/aikit/permissions.py +345 -0
  42. package/runtime/cli/aikit/personality.py +123 -0
  43. package/runtime/cli/aikit/providers.py +4 -5
  44. package/runtime/cli/aikit/scheduler.py +18 -0
  45. package/runtime/cli/aikit/sessions.py +396 -0
  46. package/runtime/cli/aikit/setup_wizard.py +12 -0
  47. package/runtime/cli/aikit/tasks.py +351 -0
  48. package/runtime/cli/aikit/toolchain.py +274 -0
  49. package/runtime/plugins/claude-code-ai-devkit/plugin.json +1 -1
  50. package/runtime/plugins/claude-skill-ai-devkit/plugin.json +1 -1
  51. package/runtime/providers/calendar.yaml +28 -0
  52. package/runtime/providers/github.yaml +42 -0
  53. package/runtime/providers/local-notification.yaml +19 -0
  54. package/runtime/providers/local-scheduler.yaml +24 -0
  55. package/runtime/scripts/README.md +1 -1
  56. package/runtime/scripts/verify-release-alignment.mjs +1 -1
  57. package/runtime/vendor/skills/napkin/napkin.md +13 -3
@@ -13,6 +13,9 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any
15
15
 
16
+ from cli.aikit.app_home import app_home, config_path as app_config_path, ensure_app_home
17
+ from cli.aikit.identity import host_cli_prompt, identity_system_prompt
18
+
16
19
 
17
20
  @dataclass(frozen=True)
18
21
  class LlmBackend:
@@ -97,17 +100,15 @@ BACKENDS: dict[str, LlmBackend] = {
97
100
 
98
101
  ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
99
102
  DEFAULT_AGENT_TIMEOUT_SECONDS = 120
103
+ DEFAULT_FALLBACK_ORDER = ("claude-code", "codex-cli", "openai", "anthropic", "openrouter", "ollama")
100
104
 
101
105
 
102
106
  def config_home() -> Path:
103
- raw = os.environ.get("AIKIT_CONFIG_HOME") or os.environ.get("AI_DEVKIT_CONFIG_HOME")
104
- if raw:
105
- return Path(raw).expanduser().resolve()
106
- return (Path.home() / ".ai-devkit").resolve()
107
+ return app_home()
107
108
 
108
109
 
109
110
  def config_path() -> Path:
110
- return config_home() / "config.json"
111
+ return app_config_path()
111
112
 
112
113
 
113
114
  def empty_config() -> dict[str, Any]:
@@ -137,6 +138,7 @@ def load_config() -> dict[str, Any]:
137
138
 
138
139
 
139
140
  def save_config(config: dict[str, Any]) -> Path:
141
+ ensure_app_home()
140
142
  path = config_path()
141
143
  path.parent.mkdir(parents=True, exist_ok=True)
142
144
  with path.open("w", encoding="utf-8") as file:
@@ -156,10 +158,12 @@ def list_backends() -> dict[str, Any]:
156
158
  config = load_config()
157
159
  default_backend = config.get("llm", {}).get("default")
158
160
  configured = set(config.get("llm", {}).get("backends", {}))
161
+ preference = llm_preference(config)
159
162
  return {
160
163
  "kind": "llm-backends",
161
164
  "config_path": str(config_path()),
162
165
  "default": default_backend,
166
+ "preference": preference,
163
167
  "items": [
164
168
  {
165
169
  "id": backend.id,
@@ -252,6 +256,68 @@ def set_default_backend(backend_id: str) -> dict[str, Any]:
252
256
  }
253
257
 
254
258
 
259
+ def llm_preference(config: dict[str, Any] | None = None) -> dict[str, Any]:
260
+ config = config or load_config()
261
+ llm = config.get("llm") if isinstance(config.get("llm"), dict) else {}
262
+ raw_preference = llm.get("preference") if isinstance(llm.get("preference"), dict) else {}
263
+ primary = raw_preference.get("primary") or llm.get("default")
264
+ order = raw_preference.get("order") if isinstance(raw_preference.get("order"), list) else None
265
+ normalized_order = normalize_backend_order(order or DEFAULT_FALLBACK_ORDER)
266
+ if primary and primary in BACKENDS:
267
+ normalized_order = [primary, *[item for item in normalized_order if item != primary]]
268
+ return {
269
+ "kind": "llm-preference",
270
+ "status": "ok",
271
+ "config_path": str(config_path()),
272
+ "primary": primary,
273
+ "order": normalized_order,
274
+ "fallback_enabled": bool(raw_preference.get("fallback_enabled", True)),
275
+ }
276
+
277
+
278
+ def set_llm_preference(
279
+ *,
280
+ primary: str | None = None,
281
+ order: str | list[str] | tuple[str, ...] | None = None,
282
+ ) -> dict[str, Any]:
283
+ config = load_config()
284
+ llm = config.setdefault("llm", {"default": None, "backends": {}})
285
+ backends = llm.setdefault("backends", {})
286
+ preference = llm.setdefault("preference", {})
287
+ if not isinstance(preference, dict):
288
+ preference = {}
289
+ llm["preference"] = preference
290
+
291
+ if primary:
292
+ backend = backend_or_error(primary)
293
+ backends.setdefault(backend.id, default_backend_config(backend))
294
+ llm["default"] = backend.id
295
+ preference["primary"] = backend.id
296
+ if order is not None:
297
+ preference["order"] = normalize_backend_order(order)
298
+ preference.setdefault("fallback_enabled", True)
299
+ written_path = save_config(config)
300
+ payload = llm_preference(config)
301
+ payload.update({"status": "configured", "config_path": str(written_path), "stored_secret": False})
302
+ return payload
303
+
304
+
305
+ def normalize_backend_order(order: str | list[str] | tuple[str, ...]) -> list[str]:
306
+ raw_items = order.split(",") if isinstance(order, str) else list(order)
307
+ normalized: list[str] = []
308
+ for raw_item in raw_items:
309
+ backend_id = str(raw_item).strip()
310
+ if not backend_id:
311
+ continue
312
+ backend_or_error(backend_id)
313
+ if backend_id not in normalized:
314
+ normalized.append(backend_id)
315
+ for backend_id in DEFAULT_FALLBACK_ORDER:
316
+ if backend_id not in normalized:
317
+ normalized.append(backend_id)
318
+ return normalized
319
+
320
+
255
321
  def default_backend_config(backend: LlmBackend) -> dict[str, Any]:
256
322
  entry: dict[str, Any] = {"kind": backend.kind, "auth": backend.auth}
257
323
  if backend.auth == "api-key-env":
@@ -359,31 +425,54 @@ def resolve_backend(requested: str | None = None) -> dict[str, Any] | None:
359
425
  backend_or_error(requested)
360
426
  return doctor_backend(BACKENDS[requested], config)
361
427
 
362
- default_backend = config.get("llm", {}).get("default")
363
- if default_backend:
364
- backend_or_error(default_backend)
365
- return doctor_backend(BACKENDS[default_backend], config)
366
-
367
- configured = config.get("llm", {}).get("backends", {})
368
- if isinstance(configured, dict):
369
- for backend_id in configured:
370
- if backend_id in BACKENDS:
371
- check = doctor_backend(BACKENDS[backend_id], config)
372
- if check.get("status") == "ok":
373
- return check
428
+ for backend_id in candidate_backend_ids(config=config, allow_fallback=True):
429
+ check = doctor_backend(BACKENDS[backend_id], config)
430
+ if check.get("status") == "ok":
431
+ return check
374
432
 
375
433
  return None
376
434
 
377
435
 
378
- def invoke_agent_prompt(prompt: str, requested: str | None = None) -> dict[str, Any]:
379
- backend = resolve_backend(requested)
380
- if not backend or backend.get("status") != "ok":
436
+ def candidate_backend_ids(
437
+ *,
438
+ config: dict[str, Any],
439
+ requested: str | None = None,
440
+ allow_fallback: bool = True,
441
+ ) -> list[str]:
442
+ if requested:
443
+ backend_or_error(requested)
444
+ return [requested]
445
+ llm = config.get("llm") if isinstance(config.get("llm"), dict) else {}
446
+ configured = llm.get("backends") if isinstance(llm.get("backends"), dict) else {}
447
+ configured_ids = [backend_id for backend_id in configured if backend_id in BACKENDS]
448
+ if not configured_ids:
449
+ return []
450
+ preference = llm_preference(config)
451
+ ordered = [backend_id for backend_id in preference["order"] if backend_id in configured_ids]
452
+ for backend_id in configured_ids:
453
+ if backend_id not in ordered:
454
+ ordered.append(backend_id)
455
+ return ordered if allow_fallback else ordered[:1]
456
+
457
+
458
+ def invoke_agent_prompt(
459
+ prompt: str,
460
+ requested: str | None = None,
461
+ *,
462
+ public_name: str = "Agent DevKit",
463
+ allow_fallback: bool = True,
464
+ ) -> dict[str, Any]:
465
+ config = load_config()
466
+ candidate_ids = candidate_backend_ids(config=config, requested=requested, allow_fallback=allow_fallback)
467
+ if not candidate_ids:
381
468
  return {
382
469
  "kind": "agent",
383
470
  "status": "blocked",
384
471
  "ok": False,
385
472
  "requires_llm": True,
386
473
  "llm_backend": requested,
474
+ "llm_backend_attempts": [],
475
+ "llm_fallback_enabled": allow_fallback,
387
476
  "prompt_received": True,
388
477
  "prompt_length": len(prompt),
389
478
  "message": "agent requires a configured LLM backend for natural-language tasks.",
@@ -395,36 +484,107 @@ def invoke_agent_prompt(prompt: str, requested: str | None = None) -> dict[str,
395
484
  "exit_code": 2,
396
485
  }
397
486
 
398
- try:
399
- response = invoke_resolved_backend(backend, prompt)
400
- except LlmInvocationError as exc:
487
+ attempts: list[dict[str, Any]] = []
488
+ last_error: str | None = None
489
+ for backend_id in candidate_ids:
490
+ backend = doctor_backend(BACKENDS[backend_id], config)
491
+ attempt = {"id": backend_id, "status": backend.get("status")}
492
+ attempts.append(attempt)
493
+ if backend.get("status") != "ok":
494
+ attempt["message"] = backend.get("message")
495
+ continue
496
+ try:
497
+ response = invoke_resolved_backend(backend, prompt, public_name=public_name)
498
+ except LlmPolicyError as exc:
499
+ attempt["status"] = "policy-blocked"
500
+ attempt["message"] = str(exc)
501
+ return failed_agent_payload(
502
+ prompt,
503
+ backend=backend,
504
+ attempts=attempts,
505
+ message=str(exc),
506
+ policy_error=True,
507
+ allow_fallback=allow_fallback,
508
+ )
509
+ except LlmInvocationError as exc:
510
+ attempt["status"] = "failed"
511
+ attempt["message"] = str(exc)
512
+ last_error = str(exc)
513
+ if requested or not allow_fallback:
514
+ break
515
+ continue
516
+ attempt["status"] = "ok"
401
517
  return {
402
518
  "kind": "agent",
403
- "status": "failed",
404
- "ok": False,
519
+ "status": "ok",
520
+ "ok": True,
405
521
  "requires_llm": True,
406
522
  "llm_backend": backend["id"],
407
523
  "llm_backend_status": backend["status"],
524
+ "llm_backend_attempts": attempts,
525
+ "llm_fallback_enabled": allow_fallback,
526
+ "llm_fallback_used": attempts[0]["id"] != backend["id"],
408
527
  "prompt_received": True,
409
528
  "prompt_length": len(prompt),
410
- "message": str(exc),
411
- "next_steps": [
412
- "Run `agent llm doctor` to verify the selected backend.",
413
- "Use `agent run <agent> <capability>` when deterministic execution is enough.",
414
- ],
415
- "exit_code": 1,
529
+ "response": response,
416
530
  }
417
531
 
532
+ if last_error:
533
+ return failed_agent_payload(
534
+ prompt,
535
+ backend={"id": attempts[-1]["id"], "status": attempts[-1].get("status")},
536
+ attempts=attempts,
537
+ message=last_error,
538
+ policy_error=False,
539
+ allow_fallback=allow_fallback,
540
+ )
418
541
  return {
419
542
  "kind": "agent",
420
- "status": "ok",
421
- "ok": True,
543
+ "status": "blocked",
544
+ "ok": False,
422
545
  "requires_llm": True,
423
- "llm_backend": backend["id"],
424
- "llm_backend_status": backend["status"],
546
+ "llm_backend": requested,
547
+ "llm_backend_attempts": attempts,
548
+ "llm_fallback_enabled": allow_fallback,
425
549
  "prompt_received": True,
426
550
  "prompt_length": len(prompt),
427
- "response": response,
551
+ "message": "agent did not find an available configured LLM backend.",
552
+ "next_steps": [
553
+ "Run `agent llm doctor` to verify configured backends.",
554
+ "Configure a backend with `agent llm configure <backend> --set-default`.",
555
+ "Adjust fallback order with `agent llm preference set --primary <backend>`.",
556
+ ],
557
+ "exit_code": 2,
558
+ }
559
+
560
+
561
+ def failed_agent_payload(
562
+ prompt: str,
563
+ *,
564
+ backend: dict[str, Any],
565
+ attempts: list[dict[str, Any]],
566
+ message: str,
567
+ policy_error: bool,
568
+ allow_fallback: bool,
569
+ ) -> dict[str, Any]:
570
+ return {
571
+ "kind": "agent",
572
+ "status": "failed",
573
+ "ok": False,
574
+ "requires_llm": True,
575
+ "llm_backend": backend.get("id"),
576
+ "llm_backend_status": backend.get("status"),
577
+ "llm_backend_attempts": attempts,
578
+ "llm_fallback_enabled": allow_fallback,
579
+ "llm_policy_error": policy_error,
580
+ "prompt_received": True,
581
+ "prompt_length": len(prompt),
582
+ "message": message,
583
+ "next_steps": [
584
+ "Run `agent llm doctor` to verify the selected backend.",
585
+ "Use `agent run <agent> <capability>` when deterministic execution is enough.",
586
+ ],
587
+ "exit_code": 1,
428
588
  }
429
589
 
430
590
 
@@ -432,21 +592,33 @@ class LlmInvocationError(RuntimeError):
432
592
  """Raised for safe, user-facing LLM invocation errors."""
433
593
 
434
594
 
435
- def invoke_resolved_backend(backend: dict[str, Any], prompt: str) -> str:
595
+ class LlmPolicyError(LlmInvocationError):
596
+ """Raised when the backend rejects content for policy or safety reasons."""
597
+
598
+
599
+ def invoke_resolved_backend(backend: dict[str, Any], prompt: str, *, public_name: str = "Agent DevKit") -> str:
436
600
  kind = backend.get("kind")
437
601
  backend_id = backend.get("id")
438
602
  if kind == "openai-compatible":
439
- return invoke_openai_compatible(backend, prompt)
603
+ return invoke_openai_compatible(backend, prompt, public_name=public_name)
440
604
  if kind == "anthropic":
441
- return invoke_anthropic(backend, prompt)
605
+ return invoke_anthropic(backend, prompt, public_name=public_name)
442
606
  if kind == "host-cli" and backend_id == "codex-cli":
443
- return invoke_host_cli([str(backend.get("command") or "codex"), "exec", "--skip-git-repo-check", "--ephemeral", prompt])
607
+ return invoke_host_cli(
608
+ [
609
+ str(backend.get("command") or "codex"),
610
+ "exec",
611
+ "--skip-git-repo-check",
612
+ "--ephemeral",
613
+ host_cli_prompt(prompt, name=public_name),
614
+ ]
615
+ )
444
616
  if kind == "host-cli" and backend_id == "claude-code":
445
- return invoke_host_cli([str(backend.get("command") or "claude"), "--print", "--permission-mode", "plan", prompt])
617
+ return invoke_host_cli([str(backend.get("command") or "claude"), "--print", "--permission-mode", "plan", host_cli_prompt(prompt, name=public_name)])
446
618
  raise LlmInvocationError(f"LLM backend is not invokable by agent yet: {backend_id}")
447
619
 
448
620
 
449
- def invoke_openai_compatible(backend: dict[str, Any], prompt: str) -> str:
621
+ def invoke_openai_compatible(backend: dict[str, Any], prompt: str, *, public_name: str = "Agent DevKit") -> str:
450
622
  base_url = str(backend.get("base_url") or "").rstrip("/")
451
623
  model = str(backend.get("model") or "")
452
624
  if not base_url or not model:
@@ -460,10 +632,7 @@ def invoke_openai_compatible(backend: dict[str, Any], prompt: str) -> str:
460
632
  "messages": [
461
633
  {
462
634
  "role": "system",
463
- "content": (
464
- "You are AI DevKit's natural-language router. Answer concisely, "
465
- "prefer deterministic agent/capability commands when useful, and never ask for all credentials upfront."
466
- ),
635
+ "content": identity_system_prompt(name=public_name),
467
636
  },
468
637
  {"role": "user", "content": prompt},
469
638
  ],
@@ -475,7 +644,7 @@ def invoke_openai_compatible(backend: dict[str, Any], prompt: str) -> str:
475
644
  raise LlmInvocationError("OpenAI-compatible backend returned an unexpected response shape.") from exc
476
645
 
477
646
 
478
- def invoke_anthropic(backend: dict[str, Any], prompt: str) -> str:
647
+ def invoke_anthropic(backend: dict[str, Any], prompt: str, *, public_name: str = "Agent DevKit") -> str:
479
648
  api_key = api_key_from_ref(backend.get("api_key_ref"))
480
649
  if not api_key:
481
650
  raise LlmInvocationError("Anthropic backend is missing an API key environment variable.")
@@ -491,7 +660,7 @@ def invoke_anthropic(backend: dict[str, Any], prompt: str) -> str:
491
660
  body = {
492
661
  "model": model,
493
662
  "max_tokens": 1024,
494
- "system": "You are AI DevKit's natural-language router. Answer concisely and prefer deterministic commands when useful.",
663
+ "system": identity_system_prompt(name=public_name),
495
664
  "messages": [{"role": "user", "content": prompt}],
496
665
  }
497
666
  payload = post_json(f"{base_url}/messages", headers, body)
@@ -527,6 +696,13 @@ def post_json(url: str, headers: dict[str, str], body: dict[str, Any]) -> dict[s
527
696
  with urllib.request.urlopen(request, timeout=DEFAULT_AGENT_TIMEOUT_SECONDS) as response: # noqa: S310 - user-configured backend URL.
528
697
  raw = response.read().decode("utf-8")
529
698
  except urllib.error.HTTPError as exc:
699
+ raw_error = ""
700
+ try:
701
+ raw_error = exc.read().decode("utf-8", errors="replace")
702
+ except OSError:
703
+ raw_error = ""
704
+ if is_policy_error(exc.code, raw_error):
705
+ raise LlmPolicyError(f"LLM backend policy error: {exc.code}") from exc
530
706
  raise LlmInvocationError(f"LLM backend HTTP error: {exc.code}") from exc
531
707
  except urllib.error.URLError as exc:
532
708
  raise LlmInvocationError(f"LLM backend connection failed: {exc.reason}") from exc
@@ -536,9 +712,25 @@ def post_json(url: str, headers: dict[str, str], body: dict[str, Any]) -> dict[s
536
712
  raise LlmInvocationError("LLM backend returned invalid JSON.") from exc
537
713
  if not isinstance(payload, dict):
538
714
  raise LlmInvocationError("LLM backend returned a non-object JSON payload.")
715
+ if payload_has_policy_error(payload):
716
+ raise LlmPolicyError("LLM backend returned a policy error.")
539
717
  return payload
540
718
 
541
719
 
720
+ def is_policy_error(status_code: int, body: str) -> bool:
721
+ if status_code not in {400, 403}:
722
+ return False
723
+ return any(marker in body.lower() for marker in ("policy", "safety", "content_filter", "content filter", "not allowed"))
724
+
725
+
726
+ def payload_has_policy_error(payload: dict[str, Any]) -> bool:
727
+ error = payload.get("error")
728
+ if not isinstance(error, dict):
729
+ return False
730
+ text = json.dumps(error, ensure_ascii=False).lower()
731
+ return any(marker in text for marker in ("policy", "safety", "content_filter", "content filter", "not allowed"))
732
+
733
+
542
734
  def api_key_from_ref(value: Any) -> str | None:
543
735
  ref = str(value or "")
544
736
  if not ref.startswith("env:"):