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.
- {agentic_dev_loop-0.11.1/src/agentic_dev_loop.egg-info → agentic_dev_loop-0.12.0}/PKG-INFO +77 -1
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/README.md +76 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/pyproject.toml +1 -1
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/config.py +47 -5
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/cli.py +68 -1
- agentic_dev_loop-0.12.0/src/agentic_dev_loop/engine/copilot_auth.py +224 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/llm.py +23 -1
- agentic_dev_loop-0.12.0/src/agentic_dev_loop/engine/provider_wire.py +38 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/secrets.py +94 -7
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/stages.py +3 -1
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0/src/agentic_dev_loop.egg-info}/PKG-INFO +77 -1
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/SOURCES.txt +5 -0
- agentic_dev_loop-0.12.0/tests/test_055_cli.py +190 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_cli.py +6 -4
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_config.py +65 -1
- agentic_dev_loop-0.12.0/tests/test_copilot_auth.py +197 -0
- agentic_dev_loop-0.12.0/tests/test_copilot_cli.py +90 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_graph.py +2 -2
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_llm.py +56 -0
- agentic_dev_loop-0.12.0/tests/test_provider_wire.py +118 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_secrets.py +114 -2
- agentic_dev_loop-0.11.1/tests/test_055_cli.py +0 -23
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/LICENSE +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/setup.cfg +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/paths.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/profiles/__init__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/profiles/data/dotnet-node.yaml +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/profiles/data/python.yaml +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/allowlist.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/brief_prompt.md +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/brief_prompt.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/containers.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/forbidden.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/isolation.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/__init__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/checks.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/doctor.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/init.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/recipes/dotnet-node.yaml +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/recipes/python.yaml +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/recipes.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/dev.agent.md +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/env.example +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/qa.agent.md +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates/review.agent.md +0 -0
- {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
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/templates.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/onboarding/upgrade.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/engine/sync_back.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/layout.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/page.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_029_merge_on_conflict.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_033_brief_step.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_035_finalize_nudge.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_041_brief_coverage.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_046_brief_prompt.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_doctor.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_init.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_profile_loading.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_profiles.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_recipes.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_schema_lock.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_templates.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_055_upgrade.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_actions.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_agent_error_guide.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_agents.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_build_viz_page.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_containers.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_deps.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_egress_proxy.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_isolation.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_isolation_integration.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_proc.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_publish.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_registry.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_results.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_stages.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_sync_back.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_viz.py +0 -0
- {agentic_dev_loop-0.11.1 → agentic_dev_loop-0.12.0}/tests/test_viz_demo.py +0 -0
- {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.
|
|
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.
|
|
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,
|
|
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``
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|