agentic-dev-loop 0.11.1__tar.gz → 0.12.0__tar.gz

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 (140) hide show
  1. {agentic_dev_loop-0.11.1/src/agentic_dev_loop.egg-info → agentic_dev_loop-0.12.0}/PKG-INFO +77 -1
  2. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/README.md +76 -0
  3. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/pyproject.toml +1 -1
  4. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/config.py +47 -5
  5. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/cli.py +68 -1
  6. agentic_dev_loop-0.12.0/src/agentic_dev_loop/engine/copilot_auth.py +224 -0
  7. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/llm.py +23 -1
  8. agentic_dev_loop-0.12.0/src/agentic_dev_loop/engine/provider_wire.py +38 -0
  9. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/secrets.py +94 -7
  10. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/stages.py +3 -1
  11. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0/src/agentic_dev_loop.egg-info}/PKG-INFO +77 -1
  12. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/SOURCES.txt +5 -0
  13. agentic_dev_loop-0.12.0/tests/test_055_cli.py +190 -0
  14. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_cli.py +6 -4
  15. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_config.py +65 -1
  16. agentic_dev_loop-0.12.0/tests/test_copilot_auth.py +197 -0
  17. agentic_dev_loop-0.12.0/tests/test_copilot_cli.py +90 -0
  18. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_graph.py +2 -2
  19. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_llm.py +56 -0
  20. agentic_dev_loop-0.12.0/tests/test_provider_wire.py +118 -0
  21. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_secrets.py +114 -2
  22. agentic_dev_loop-0.11.1/tests/test_055_cli.py +0 -23
  23. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/LICENSE +0 -0
  24. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/setup.cfg +0 -0
  25. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/__init__.py +0 -0
  26. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/__init__.py +0 -0
  27. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/paths.py +0 -0
  28. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/profiles/__init__.py +0 -0
  29. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/profiles/data/dotnet-node.yaml +0 -0
  30. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/profiles/data/python.yaml +0 -0
  31. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/results.py +0 -0
  32. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/verdict.py +0 -0
  33. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/__init__.py +0 -0
  34. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/actions.py +0 -0
  35. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  36. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  37. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/agents.py +0 -0
  38. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  39. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  40. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/brief_prompt.md +0 -0
  41. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/brief_prompt.py +0 -0
  42. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/containers.py +0 -0
  43. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/deps.py +0 -0
  44. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  45. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  46. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  47. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  48. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  49. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  50. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/graph.py +0 -0
  51. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  52. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/isolation.py +0 -0
  53. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/loaders.py +0 -0
  54. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/mounts.py +0 -0
  55. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/__init__.py +0 -0
  56. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/checks.py +0 -0
  57. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/doctor.py +0 -0
  58. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/init.py +0 -0
  59. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/recipes/dotnet-node.yaml +0 -0
  60. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/recipes/python.yaml +0 -0
  61. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/recipes.py +0 -0
  62. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/dev.agent.md +0 -0
  63. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/env.example +0 -0
  64. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/qa.agent.md +0 -0
  65. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/review.agent.md +0 -0
  66. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/trigger-agentic-dev-loop.sh +0 -0
  67. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates.py +0 -0
  68. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/upgrade.py +0 -0
  69. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/outputs.py +0 -0
  70. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/presets.py +0 -0
  71. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/proc.py +0 -0
  72. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/registry.py +0 -0
  73. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  74. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  75. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  76. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  77. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/py.typed +0 -0
  78. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/__init__.py +0 -0
  79. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/__main__.py +0 -0
  80. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/layout.py +0 -0
  81. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/page.py +0 -0
  82. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/server.py +0 -0
  83. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  84. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  85. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  86. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  87. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_029_merge_on_conflict.py +0 -0
  88. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_033_brief_step.py +0 -0
  89. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_035_finalize_nudge.py +0 -0
  90. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  91. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_041_brief_coverage.py +0 -0
  92. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_046_brief_prompt.py +0 -0
  93. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_doctor.py +0 -0
  94. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_init.py +0 -0
  95. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_profile_loading.py +0 -0
  96. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_profiles.py +0 -0
  97. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_recipes.py +0 -0
  98. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_schema_lock.py +0 -0
  99. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_templates.py +0 -0
  100. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_upgrade.py +0 -0
  101. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_actions.py +0 -0
  102. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_agent_error_guide.py +0 -0
  103. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_agents.py +0 -0
  104. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_aidlc.py +0 -0
  105. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_build_viz_page.py +0 -0
  106. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_containers.py +0 -0
  107. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_deps.py +0 -0
  108. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_dry_run.py +0 -0
  109. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_e2e_fixtures.py +0 -0
  110. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_egress_proxy.py +0 -0
  111. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_hostboundary.py +0 -0
  112. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_hostboundary_integration.py +0 -0
  113. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_isolation.py +0 -0
  114. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_isolation_integration.py +0 -0
  115. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_isolation_pbt.py +0 -0
  116. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_loaders.py +0 -0
  117. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_module_contract.py +0 -0
  118. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_outputs.py +0 -0
  119. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_outputs_pbt.py +0 -0
  120. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_proc.py +0 -0
  121. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_publish.py +0 -0
  122. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_qa_prompt.py +0 -0
  123. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_registry.py +0 -0
  124. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_repo_ascii.py +0 -0
  125. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_results.py +0 -0
  126. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_sdk_compat.py +0 -0
  127. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_secrets_integration.py +0 -0
  128. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_secrets_pipeline.py +0 -0
  129. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_source_resolution.py +0 -0
  130. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_stages.py +0 -0
  131. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_step_workspace.py +0 -0
  132. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_sync_back.py +0 -0
  133. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_trigger_script.py +0 -0
  134. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_unit5_config.py +0 -0
  135. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_unit5_pbt.py +0 -0
  136. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_unit5_pure.py +0 -0
  137. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_verdict.py +0 -0
  138. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_viz.py +0 -0
  139. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_viz_demo.py +0 -0
  140. {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_viz_layout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 0.11.1
3
+ Version: 0.12.0
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -391,6 +391,12 @@ steps:
391
391
  engine-written output - captured step output, retry notes, artifacts, and
392
392
  `state_dir` files. (Hygiene: the declaring step's agent has already seen
393
393
  the value in its own env.)
394
+ - **File provider and LLM keys.** `from: file` reads a host file (`key` is the
395
+ path, optional `json_key` selects a JSON field). The `llm:` block accepts
396
+ exactly one of `api_key_env` or `api_key_secret`; the resolved key joins the
397
+ redaction set. See [Copilot as an endpoint](docs/setup.md#copilot-subscription-endpoint-058)
398
+ for the GitHub Copilot subscription profile (`copilot-login` device flow,
399
+ `copilot-models` probe, and the required endpoint headers).
394
400
 
395
401
  ### Per-stage networking
396
402
 
@@ -462,6 +468,76 @@ lines, the sandbox's new lines are appended in order, both sides converge, and
462
468
  the run continues with `[isolation] merged concurrent edits: ...` on the
463
469
  console.
464
470
 
471
+ ### Copilot subscription endpoint
472
+
473
+ GitHub Copilot subscriptions work as an OpenAI-compatible LLM endpoint: log in
474
+ once, then point an `llm:` block at it. The smoke harness runs the same plan
475
+ against both the Copilot endpoint and a standard OpenAI-compatible endpoint,
476
+ so the provider choice is independent of the pipeline design.
477
+
478
+ ```bash
479
+ agentic-dev-loop copilot-login # device flow; writes the token (mode 600)
480
+ agentic-dev-loop copilot-models # the models this token's tier can use
481
+ ```
482
+
483
+ ```yaml
484
+ llm:
485
+ model: openai/gpt-5.6-luna # pro default; probe-validated (see below)
486
+ base_url: https://api.githubcopilot.com
487
+ api_key_secret:
488
+ from: file
489
+ key: ~/.agentic-dev-loop/auth/copilot.json
490
+ json_key: access_token
491
+ headers:
492
+ Copilot-Integration-Id: vscode-chat
493
+ Editor-Version: vscode/1.94.0
494
+ Openai-Intent: conversation-panel
495
+ llms:
496
+ flash:
497
+ model: openai/gpt-4.1 # lite tier; no reasoning_effort (see below)
498
+ base_url: https://api.githubcopilot.com
499
+ api_key_secret:
500
+ from: file
501
+ key: ~/.agentic-dev-loop/auth/copilot.json
502
+ json_key: access_token
503
+ headers:
504
+ Copilot-Integration-Id: vscode-chat
505
+ Editor-Version: vscode/1.94.0
506
+ Openai-Intent: conversation-panel
507
+ ```
508
+
509
+ - The `openai/` prefix on model names is required: litellm otherwise routes a
510
+ bare `claude-*` name through Anthropic's wire format, which the gateway
511
+ rejects. The prefix forces the OpenAI-compatible route.
512
+ - The model pins are probe-validated against the live endpoint (2026-11-06).
513
+ `gpt-5.6-luna` routes through the Responses API (the SDK switches
514
+ automatically), accepts `xhigh`, and returns real verdicts on the review
515
+ step. `gpt-4.1` tool-calls cleanly on the chat path but hard-400s on any
516
+ `reasoning_effort`, so lite-tier steps set none.
517
+ - The brief step runs on the pro model at `medium`: no-effort `gpt-4.1`
518
+ cannot satisfy the mechanical brief-coverage gate.
519
+ - The engine forces string-encoded message content for Copilot configs: the
520
+ gateway silently drops ARRAY-encoded tool results, which would leave an
521
+ agent blind to its own terminal output.
522
+
523
+ Known limitations (probe-validated, not speculation):
524
+
525
+ - Anthropic models are unusable for agent steps on this endpoint: the claude
526
+ pins collapse to tool-less narration on review (planning-docs/059), and
527
+ claude-opus-4.8 returns two choices on the non-streamed path the engine
528
+ uses.
529
+ - `gpt-4o-mini` caps chat context at 12288 tokens (it overran on the first
530
+ smoke run).
531
+ - `gpt-4o` and `gpt-4.1` reject any `reasoning_effort` on the chat path.
532
+ - `gpt-5.x` models are Responses-API-only.
533
+ - kimi-k3 accepts only `low`/`high`/`max` effort, and its tool results were
534
+ dropped by the gateway until the string-content policy above.
535
+ - `gpt-4.1` carries a gateway "utility model" rate limit that a sustained dev
536
+ loop can trip; it resets within minutes.
537
+
538
+ See [docs/setup.md](docs/setup.md#copilot-subscription-endpoint-058) for the
539
+ token lifecycle, the probe-validated headers, and the full details.
540
+
465
541
 
466
542
  ## Authoring a loop for your own repo
467
543
 
@@ -347,6 +347,12 @@ steps:
347
347
  engine-written output - captured step output, retry notes, artifacts, and
348
348
  `state_dir` files. (Hygiene: the declaring step's agent has already seen
349
349
  the value in its own env.)
350
+ - **File provider and LLM keys.** `from: file` reads a host file (`key` is the
351
+ path, optional `json_key` selects a JSON field). The `llm:` block accepts
352
+ exactly one of `api_key_env` or `api_key_secret`; the resolved key joins the
353
+ redaction set. See [Copilot as an endpoint](docs/setup.md#copilot-subscription-endpoint-058)
354
+ for the GitHub Copilot subscription profile (`copilot-login` device flow,
355
+ `copilot-models` probe, and the required endpoint headers).
350
356
 
351
357
  ### Per-stage networking
352
358
 
@@ -418,6 +424,76 @@ lines, the sandbox's new lines are appended in order, both sides converge, and
418
424
  the run continues with `[isolation] merged concurrent edits: ...` on the
419
425
  console.
420
426
 
427
+ ### Copilot subscription endpoint
428
+
429
+ GitHub Copilot subscriptions work as an OpenAI-compatible LLM endpoint: log in
430
+ once, then point an `llm:` block at it. The smoke harness runs the same plan
431
+ against both the Copilot endpoint and a standard OpenAI-compatible endpoint,
432
+ so the provider choice is independent of the pipeline design.
433
+
434
+ ```bash
435
+ agentic-dev-loop copilot-login # device flow; writes the token (mode 600)
436
+ agentic-dev-loop copilot-models # the models this token's tier can use
437
+ ```
438
+
439
+ ```yaml
440
+ llm:
441
+ model: openai/gpt-5.6-luna # pro default; probe-validated (see below)
442
+ base_url: https://api.githubcopilot.com
443
+ api_key_secret:
444
+ from: file
445
+ key: ~/.agentic-dev-loop/auth/copilot.json
446
+ json_key: access_token
447
+ headers:
448
+ Copilot-Integration-Id: vscode-chat
449
+ Editor-Version: vscode/1.94.0
450
+ Openai-Intent: conversation-panel
451
+ llms:
452
+ flash:
453
+ model: openai/gpt-4.1 # lite tier; no reasoning_effort (see below)
454
+ base_url: https://api.githubcopilot.com
455
+ api_key_secret:
456
+ from: file
457
+ key: ~/.agentic-dev-loop/auth/copilot.json
458
+ json_key: access_token
459
+ headers:
460
+ Copilot-Integration-Id: vscode-chat
461
+ Editor-Version: vscode/1.94.0
462
+ Openai-Intent: conversation-panel
463
+ ```
464
+
465
+ - The `openai/` prefix on model names is required: litellm otherwise routes a
466
+ bare `claude-*` name through Anthropic's wire format, which the gateway
467
+ rejects. The prefix forces the OpenAI-compatible route.
468
+ - The model pins are probe-validated against the live endpoint (2026-11-06).
469
+ `gpt-5.6-luna` routes through the Responses API (the SDK switches
470
+ automatically), accepts `xhigh`, and returns real verdicts on the review
471
+ step. `gpt-4.1` tool-calls cleanly on the chat path but hard-400s on any
472
+ `reasoning_effort`, so lite-tier steps set none.
473
+ - The brief step runs on the pro model at `medium`: no-effort `gpt-4.1`
474
+ cannot satisfy the mechanical brief-coverage gate.
475
+ - The engine forces string-encoded message content for Copilot configs: the
476
+ gateway silently drops ARRAY-encoded tool results, which would leave an
477
+ agent blind to its own terminal output.
478
+
479
+ Known limitations (probe-validated, not speculation):
480
+
481
+ - Anthropic models are unusable for agent steps on this endpoint: the claude
482
+ pins collapse to tool-less narration on review (planning-docs/059), and
483
+ claude-opus-4.8 returns two choices on the non-streamed path the engine
484
+ uses.
485
+ - `gpt-4o-mini` caps chat context at 12288 tokens (it overran on the first
486
+ smoke run).
487
+ - `gpt-4o` and `gpt-4.1` reject any `reasoning_effort` on the chat path.
488
+ - `gpt-5.x` models are Responses-API-only.
489
+ - kimi-k3 accepts only `low`/`high`/`max` effort, and its tool results were
490
+ dropped by the gateway until the string-content policy above.
491
+ - `gpt-4.1` carries a gateway "utility model" rate limit that a sustained dev
492
+ loop can trip; it resets within minutes.
493
+
494
+ See [docs/setup.md](docs/setup.md#copilot-subscription-endpoint-058) for the
495
+ token lifecycle, the probe-validated headers, and the full details.
496
+
421
497
 
422
498
  ## Authoring a loop for your own repo
423
499
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "0.11.1"
7
+ version = "0.12.0"
8
8
  description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -10,12 +10,13 @@ from pathlib import Path
10
10
  from typing import Any, Literal
11
11
 
12
12
  import yaml
13
- from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
13
+ from pydantic import (BaseModel, ConfigDict, Field, field_validator,
14
+ model_serializer, model_validator)
14
15
 
15
16
  from agentic_dev_loop.core.paths import _has_wildcards, path_excluded, relative_parts
16
17
 
17
18
  TERMINALS = frozenset({"success", "failure"})
18
- KNOWN_SECRET_PROVIDERS = frozenset({"env"})
19
+ KNOWN_SECRET_PROVIDERS = frozenset({"env", "file"})
19
20
  MOUNT_MODES = frozenset({"rw", "ro"})
20
21
  # The egress preset names the engine ships (BR-9). Config validates the
21
22
  # name here; the target data lives in engine/presets.py (preset data,
@@ -183,14 +184,17 @@ def route_target(route: RouteTarget | None) -> str | None:
183
184
 
184
185
  class SecretSpec(BaseModel):
185
186
  """One ``secrets:`` ref - the locator only, never a value. ``from`` names
186
- the provider (``env`` first); ``key`` is that provider's locator (for
187
+ the provider (``env`` or ``file``); ``key`` is that provider's locator (for
187
188
  ``env``, the host environment variable to read and the step env name to
188
- inject). Unknown keys fail at load (extra="forbid")."""
189
+ inject; for ``file``, the JSON file path). ``json_key`` selects a key
190
+ inside the JSON file and is file-provider-only. Unknown keys fail at load
191
+ (extra="forbid")."""
189
192
 
190
193
  model_config = ConfigDict(extra="forbid", populate_by_name=True)
191
194
 
192
195
  from_: str = Field(alias="from")
193
196
  key: str
197
+ json_key: str | None = None
194
198
 
195
199
  @field_validator("from_")
196
200
  @classmethod
@@ -208,6 +212,28 @@ class SecretSpec(BaseModel):
208
212
  raise ValueError("secret key must be a non-empty string")
209
213
  return v
210
214
 
215
+ @field_validator("json_key")
216
+ @classmethod
217
+ def _nonempty_json_key(cls, v: str | None) -> str | None:
218
+ if v is not None and not v.strip():
219
+ raise ValueError("secret json_key must be a non-empty string")
220
+ return v
221
+
222
+ @model_validator(mode="after")
223
+ def _json_key_only_for_file(self) -> "SecretSpec":
224
+ if self.json_key is not None and self.from_ != "file":
225
+ raise ValueError(
226
+ "secret json_key is only valid with the 'file' provider")
227
+ return self
228
+
229
+ @model_serializer(mode="wrap")
230
+ def _omit_absent_json_key(self, handler):
231
+ """Keep the pre-058 env-provider dump shape byte-identical."""
232
+ data = handler(self)
233
+ if data.get("json_key") is None:
234
+ data.pop("json_key", None)
235
+ return data
236
+
211
237
 
212
238
  class IsolationConfig(BaseModel):
213
239
  """Optional `isolation:` section - configures the always-on sandbox (BR-2).
@@ -600,13 +626,29 @@ class LlmConfig(BaseModel):
600
626
 
601
627
  model: str
602
628
  base_url: str | None = None
603
- api_key_env: str
629
+ # 058 (D3): exactly one host-side key mechanism - an environment variable
630
+ # (the existing shape) or a file-backed secret spec.
631
+ api_key_env: str | None = None
632
+ api_key_secret: SecretSpec | None = None
633
+ # 058 (D2): extra HTTP headers forwarded verbatim to the SDK LLM
634
+ # (``extra_headers``). Empty = no change for every existing pipeline.
635
+ headers: dict[str, str] = Field(default_factory=dict)
604
636
  timeout: float | None = None # omitted → opinionated no-timeout default
605
637
  # 047 (D1) / 054 (D6): opt-in passthrough; unset means reasoning is off
606
638
  # (the engine forwards none and strips reasoning passback). The SDK
607
639
  # "high" default never engages because build_llm always passes the value.
608
640
  reasoning_effort: ReasoningEffort | None = None
609
641
 
642
+ @model_validator(mode="after")
643
+ def _exactly_one_api_key_mechanism(self) -> "LlmConfig":
644
+ has_env = self.api_key_env is not None
645
+ has_secret = self.api_key_secret is not None
646
+ if has_env == has_secret:
647
+ raise ValueError(
648
+ "set exactly one of 'api_key_env' or 'api_key_secret' on the "
649
+ "llm block (neither or both are invalid)")
650
+ return self
651
+
610
652
 
611
653
  class PipelineConfig(BaseModel):
612
654
  model_config = ConfigDict(extra="forbid")
@@ -10,9 +10,12 @@ import json
10
10
  import os
11
11
  import signal
12
12
  import sys
13
+ import threading
13
14
  from pathlib import Path
14
15
 
15
16
  from agentic_dev_loop.core.config import PipelineConfig, load_config
17
+ from .copilot_auth import (TOKEN_PATH, CopilotAuthError, list_copilot_models,
18
+ login, read_token)
16
19
  from .containers import containers_report
17
20
  from .isolation import isolation_state_dir, reap_orphaned_containers
18
21
  from .loaders import mark_plan_completed
@@ -46,6 +49,14 @@ def _restore_sigterm_handler(previous) -> None:
46
49
  signal.signal(signal.SIGTERM, previous)
47
50
 
48
51
 
52
+ def _wait_for_interrupt() -> None:
53
+ """Block until the user presses ctrl+c; a bounded wait keeps the loop
54
+ responsive to signals without requiring a tty."""
55
+ idle = threading.Event()
56
+ while not idle.wait(1.0):
57
+ pass
58
+
59
+
49
60
  def _version() -> str:
50
61
  """The installed distribution version - the single source of truth."""
51
62
  try:
@@ -149,6 +160,52 @@ def _print_containers_report(report) -> None:
149
160
  f"run={run} pid={pid_s}")
150
161
 
151
162
 
163
+ def _token_path(value: str | None) -> Path:
164
+ return Path(value).expanduser() if value else TOKEN_PATH.expanduser()
165
+
166
+
167
+ def _run_copilot_login(argv: list[str]) -> int:
168
+ parser = argparse.ArgumentParser(
169
+ prog="agentic-dev-loop copilot-login",
170
+ description="Run the GitHub device flow and store the Copilot OAuth "
171
+ "token (~/.agentic-dev-loop/auth/copilot.json, mode 600).")
172
+ parser.add_argument("--token-file", default=None,
173
+ help="token path (default: "
174
+ "~/.agentic-dev-loop/auth/copilot.json)")
175
+ args = parser.parse_args(argv)
176
+ path = _token_path(args.token_file)
177
+ try:
178
+ login(token_path=path)
179
+ except CopilotAuthError as e:
180
+ print(f"copilot-login: {e}", file=sys.stderr)
181
+ return 1
182
+ print(f"Copilot token written to {path}")
183
+ return 0
184
+
185
+
186
+ def _run_copilot_models(argv: list[str]) -> int:
187
+ parser = argparse.ArgumentParser(
188
+ prog="agentic-dev-loop copilot-models",
189
+ description="List the Copilot models available to this token's tier.")
190
+ parser.add_argument("--token-file", default=None,
191
+ help="token path (default: "
192
+ "~/.agentic-dev-loop/auth/copilot.json)")
193
+ parser.add_argument("--json", action="store_true",
194
+ help="emit the model list as JSON")
195
+ args = parser.parse_args(argv)
196
+ try:
197
+ models = list_copilot_models(read_token(_token_path(args.token_file)))
198
+ except CopilotAuthError as e:
199
+ print(f"copilot-models: {e}", file=sys.stderr)
200
+ return 1
201
+ if args.json:
202
+ print(json.dumps({"models": models}, sort_keys=True))
203
+ else:
204
+ for model in models:
205
+ print(model)
206
+ return 0
207
+
208
+
152
209
  def build_parser(cfg_path) -> argparse.ArgumentParser:
153
210
  """The full parser for one pipeline: core flags + loader-contributed."""
154
211
  registry, _ = _registry_for(cfg_path)
@@ -158,13 +215,17 @@ def build_parser(cfg_path) -> argparse.ArgumentParser:
158
215
  return parser
159
216
 
160
217
 
161
- def main(argv: list[str] | None = None) -> int:
218
+ def main(argv: list[str] | None = None, *, keep_alive: bool = True) -> int:
162
219
  if argv is None:
163
220
  argv = sys.argv[1:]
164
221
  else:
165
222
  argv = list(argv)
166
223
  if argv and argv[0] == "containers":
167
224
  return _run_containers(argv[1:])
225
+ if argv and argv[0] == "copilot-login":
226
+ return _run_copilot_login(argv[1:])
227
+ if argv and argv[0] == "copilot-models":
228
+ return _run_copilot_models(argv[1:])
168
229
  if argv and argv[0] == "init":
169
230
  from .onboarding.init import run_init
170
231
  return run_init(argv[1:])
@@ -207,6 +268,12 @@ def main(argv: list[str] | None = None) -> int:
207
268
  if not success:
208
269
  print("agentic-dev-loop: pipeline failed - exiting with code 1",
209
270
  file=sys.stderr)
271
+ if keep_alive and not args.no_viz:
272
+ print("Pipeline finished: press ctrl+c to exit")
273
+ try:
274
+ _wait_for_interrupt()
275
+ except KeyboardInterrupt:
276
+ pass
210
277
  return 0 if success else 1
211
278
  finally:
212
279
  _restore_sigterm_handler(previous)
@@ -0,0 +1,224 @@
1
+ """GitHub device flow for Copilot and the Copilot model-list probe (058).
2
+
3
+ Copilot is used as an OpenAI-compatible endpoint on the existing OpenHands
4
+ runtime: the device flow produces an OAuth access token that becomes the
5
+ LLM's bearer credential. This module is stdlib-only and never runs during a
6
+ pipeline run - the ``copilot-login`` subcommand hosts the interactive flow.
7
+
8
+ The default client id belongs to OpenCode's OAuth app, not this project; it
9
+ is the zero-setup default and is overridable via
10
+ ``AGENTIC_DEV_LOOP_COPILOT_CLIENT_ID`` for production use.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ DEVICE_CODE_URL = "https://github.com/login/device/code"
25
+ TOKEN_URL = "https://github.com/login/oauth/access_token"
26
+ COPILOT_BASE_URL = "https://api.githubcopilot.com"
27
+ TOKEN_PATH = Path("~/.agentic-dev-loop/auth/copilot.json")
28
+
29
+ DEFAULT_CLIENT_ID = "Iv1.b507a08c87ecfe98"
30
+ DEFAULT_SCOPE = "copilot"
31
+ CLIENT_ID_ENV = "AGENTIC_DEV_LOOP_COPILOT_CLIENT_ID"
32
+ REQUEST_TIMEOUT = 30
33
+
34
+ # Copilot endpoint headers, probe-validated 2026-11-06 against a real token:
35
+ # these three are accepted at /models and /chat/completions.
36
+ # X-GitHub-Api-Version: 2022-11-28 is NOT here: the live endpoint rejects it
37
+ # with 400 "invalid apiVersion" at both endpoints (it was an unprobed
38
+ # community value, now falsified - see aidlc-docs/audit.md).
39
+ COPILOT_HEADERS = {
40
+ "Copilot-Integration-Id": "vscode-chat",
41
+ "Editor-Version": "vscode/1.94.0",
42
+ "Openai-Intent": "conversation-panel",
43
+ }
44
+
45
+
46
+ class CopilotAuthError(RuntimeError):
47
+ """A loud fail-fast auth failure naming the operator's next step."""
48
+
49
+
50
+ def client_id() -> str:
51
+ """The OAuth app id: the overridable env var, else the pinned default."""
52
+ return os.environ.get(CLIENT_ID_ENV) or DEFAULT_CLIENT_ID
53
+
54
+
55
+ def _open_json(request: urllib.request.Request, opener=None) -> dict:
56
+ opener = opener or urllib.request.urlopen
57
+ try:
58
+ with opener(request, timeout=REQUEST_TIMEOUT) as response:
59
+ return json.loads(response.read().decode("utf-8"))
60
+ except urllib.error.HTTPError as e:
61
+ raise CopilotAuthError(_http_error_message(e)) from e
62
+ except urllib.error.URLError as e:
63
+ raise CopilotAuthError(
64
+ f"could not reach {request.full_url}: {e.reason}") from e
65
+ except json.JSONDecodeError as e:
66
+ raise CopilotAuthError(
67
+ f"invalid JSON response from {request.full_url}") from e
68
+
69
+
70
+ def _http_error_message(error: urllib.error.HTTPError) -> str:
71
+ if error.code in (401, 403):
72
+ return (
73
+ f"GitHub rejected the token (HTTP {error.code}). If this token "
74
+ "was created before the 058 Copilot scope fix, delete "
75
+ f"{TOKEN_PATH.expanduser()} and re-run "
76
+ "`agentic-dev-loop copilot-login` (the old token has the wrong "
77
+ "scope).")
78
+ return f"GitHub request failed (HTTP {error.code}): {error.reason}"
79
+
80
+
81
+ def request_device_code(client_id_value: str | None = None,
82
+ scope: str = DEFAULT_SCOPE, opener=None) -> dict:
83
+ data = urllib.parse.urlencode({
84
+ "client_id": client_id_value or client_id(),
85
+ "scope": scope,
86
+ }).encode("utf-8")
87
+ request = urllib.request.Request(
88
+ DEVICE_CODE_URL, data=data,
89
+ headers={"Accept": "application/json",
90
+ "Content-Type": "application/x-www-form-urlencoded"},
91
+ method="POST")
92
+ return _open_json(request, opener)
93
+
94
+
95
+ def poll_for_token(device_code: str, client_id_value: str | None = None,
96
+ interval: float = 5, expires_in: float = 900,
97
+ sleep=time.sleep, opener=None) -> dict:
98
+ """Poll the token endpoint until GitHub issues a token or reports a
99
+ terminal error. ``authorization_pending`` waits; ``slow_down`` grows the
100
+ interval. Anything else (expired, denied) fails fast."""
101
+ data = urllib.parse.urlencode({
102
+ "client_id": client_id_value or client_id(),
103
+ "device_code": device_code,
104
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
105
+ }).encode("utf-8")
106
+ request = urllib.request.Request(
107
+ TOKEN_URL, data=data,
108
+ headers={"Accept": "application/json",
109
+ "Content-Type": "application/x-www-form-urlencoded"},
110
+ method="POST")
111
+ wait = max(0.0, float(interval))
112
+ deadline = time.monotonic() + max(0.0, float(expires_in))
113
+ while True:
114
+ payload = _open_json(request, opener)
115
+ if payload.get("access_token"):
116
+ return payload
117
+ error = payload.get("error")
118
+ if error == "authorization_pending":
119
+ pass
120
+ elif error == "slow_down":
121
+ wait += 5
122
+ else:
123
+ raise CopilotAuthError(
124
+ f"device flow failed: {error or 'unknown error'} - run "
125
+ "`agentic-dev-loop copilot-login` again")
126
+ if time.monotonic() >= deadline:
127
+ raise CopilotAuthError(
128
+ "the device code expired - run "
129
+ "`agentic-dev-loop copilot-login` again")
130
+ sleep(wait)
131
+
132
+
133
+ def write_token(token: dict, token_path: Path | str | None = None) -> Path:
134
+ """Write the token JSON atomically-enough with mode 600 from creation."""
135
+ path = Path(token_path).expanduser() if token_path else TOKEN_PATH.expanduser()
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ payload = json.dumps(token, indent=2, sort_keys=True) + "\n"
138
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
139
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
140
+ handle.write(payload)
141
+ os.chmod(path, 0o600)
142
+ return path
143
+
144
+
145
+ def login(token_path: Path | str | None = None, *,
146
+ client_id_value: str | None = None, scope: str = DEFAULT_SCOPE,
147
+ sleep=time.sleep, opener=None, printer=print) -> dict:
148
+ """Run the device flow and persist the token. Prints the verification URL
149
+ and user code; never called by build_llm or a pipeline run."""
150
+ device = request_device_code(
151
+ client_id_value=client_id_value, scope=scope, opener=opener)
152
+ uri = device.get("verification_uri") or device.get(
153
+ "verification_uri_complete")
154
+ code = device.get("user_code", "")
155
+ printer(f"Go to {uri} and enter code: {code}")
156
+ token = poll_for_token(
157
+ device["device_code"],
158
+ client_id_value=client_id_value,
159
+ interval=device.get("interval", 5),
160
+ expires_in=device.get("expires_in", 900),
161
+ sleep=sleep, opener=opener)
162
+ write_token(token, token_path)
163
+ return token
164
+
165
+
166
+ def _is_expired(expires_at) -> bool:
167
+ if isinstance(expires_at, (int, float)):
168
+ return time.time() >= expires_at
169
+ if isinstance(expires_at, str):
170
+ try:
171
+ moment = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
172
+ except ValueError:
173
+ return False
174
+ return datetime.now(timezone.utc) >= moment
175
+ return False
176
+
177
+
178
+ def read_token(token_path: Path | str | None = None) -> str:
179
+ """Read the stored access token. Missing, unreadable, expired, or
180
+ token-less files all fail fast with the copilot-login command."""
181
+ path = Path(token_path).expanduser() if token_path else TOKEN_PATH.expanduser()
182
+ if not path.is_file():
183
+ raise CopilotAuthError(
184
+ f"Copilot token not found at {path} - run "
185
+ "`agentic-dev-loop copilot-login`")
186
+ try:
187
+ payload = json.loads(path.read_text(encoding="utf-8"))
188
+ except (OSError, json.JSONDecodeError) as e:
189
+ raise CopilotAuthError(
190
+ f"Copilot token file {path} is unreadable: {e} - run "
191
+ "`agentic-dev-loop copilot-login` again") from e
192
+ token = payload.get("access_token") if isinstance(payload, dict) else None
193
+ if not isinstance(token, str) or not token:
194
+ raise CopilotAuthError(
195
+ f"Copilot token file {path} has no access_token - run "
196
+ "`agentic-dev-loop copilot-login` again")
197
+ if _is_expired(payload.get("expires_at")):
198
+ raise CopilotAuthError(
199
+ f"Copilot token at {path} has expired - run "
200
+ "`agentic-dev-loop copilot-login` again")
201
+ return token
202
+
203
+
204
+ def list_copilot_models(token: str, opener=None) -> list[str]:
205
+ """GET /models for this token's tier (the probe behind copilot-models and
206
+ the smoke pre-flight). Premium models are plan-gated; never assume one."""
207
+ request = urllib.request.Request(
208
+ f"{COPILOT_BASE_URL}/models",
209
+ headers={"Authorization": f"Bearer {token}",
210
+ "Accept": "application/json",
211
+ **COPILOT_HEADERS},
212
+ method="GET")
213
+ payload = _open_json(request, opener)
214
+ data = payload.get("data") if isinstance(payload, dict) else None
215
+ if not isinstance(data, list):
216
+ raise CopilotAuthError(
217
+ "unexpected response from the Copilot /models endpoint")
218
+ models = [item.get("id") for item in data
219
+ if isinstance(item, dict) and isinstance(item.get("id"), str)]
220
+ if not models:
221
+ raise CopilotAuthError(
222
+ "the Copilot /models endpoint listed no models for this token's "
223
+ "tier - run `agentic-dev-loop copilot-login` and retry")
224
+ return models
@@ -6,6 +6,9 @@ import os
6
6
  from openhands.sdk import LLM
7
7
 
8
8
  from agentic_dev_loop.core.config import LlmConfig
9
+ from agentic_dev_loop.engine.provider_wire import adapter_for
10
+ from agentic_dev_loop.engine.secrets import (
11
+ SecretResolutionError, resolve_secret_spec)
9
12
 
10
13
  # 054 (D8): reasoning passback is config-gated, never name-list-gated. The
11
14
  # SDK gates reasoning_content passback on a closed model-name list
@@ -82,20 +85,39 @@ if not getattr(_ORIGINAL_FORMAT_MESSAGES, "_agentic_dev_loop_reasoning_repair",
82
85
  LLM.format_messages_for_llm = _config_gated_reasoning_serializer
83
86
 
84
87
 
85
- def build_llm(cfg: LlmConfig, reasoning_effort: str | None = None) -> LLM:
88
+ def resolve_api_key(cfg: LlmConfig, environ=None) -> str:
89
+ """Resolve the configured host-side key: the existing env mechanism, or
90
+ the file-backed secret spec. Missing values fail fast naming the fix."""
91
+ if cfg.api_key_secret is not None:
92
+ try:
93
+ return resolve_secret_spec(cfg.api_key_secret, environ)
94
+ except SecretResolutionError as e:
95
+ raise RuntimeError(f"llm api_key_secret: {e}") from e
86
96
  api_key = os.environ.get(cfg.api_key_env)
87
97
  if not api_key:
88
98
  raise RuntimeError(f"{cfg.api_key_env} environment variable is required")
99
+ return api_key
100
+
101
+
102
+ def build_llm(cfg: LlmConfig, reasoning_effort: str | None = None) -> LLM:
103
+ api_key = resolve_api_key(cfg)
89
104
  # 054 (D6): a step's value wins, else the effective profile's value, else
90
105
  # off. reasoning_effort is always passed (None when unset) so the SDK
91
106
  # "high" default cannot engage; absent effort = reasoning off.
92
107
  effort = reasoning_effort if reasoning_effort is not None else cfg.reasoning_effort
108
+ # Copilot gateway policy (provider_wire.py): the gateway silently drops
109
+ # ARRAY-encoded tool-result content, so Copilot-configured instances
110
+ # force the SDK's documented string content serializer. Every other
111
+ # gateway keeps the SDK default (None = auto-detect by model registry).
112
+ adapter = adapter_for(cfg.base_url)
93
113
  llm = LLM(
94
114
  model=cfg.model,
95
115
  api_key=api_key,
96
116
  base_url=cfg.base_url,
97
117
  timeout=cfg.timeout, # policy: no LLM timeout unless explicitly set
98
118
  reasoning_effort=effort,
119
+ extra_headers=dict(cfg.headers),
120
+ force_string_serializer=adapter.force_string_serializer if adapter else None,
99
121
  )
100
122
  llm._agentic_dev_loop_reasoning_effort = effort
101
123
  return llm