drydock-cli 3.0.81__tar.gz → 3.0.83__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 (103) hide show
  1. {drydock_cli-3.0.81/drydock_cli.egg-info → drydock_cli-3.0.83}/PKG-INFO +46 -1
  2. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/README.md +45 -0
  3. drydock_cli-3.0.83/drydock/advisor.py +100 -0
  4. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/config.py +7 -0
  5. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/tools/__init__.py +31 -3
  6. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/tui/app.py +66 -0
  7. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/tui/widgets.py +2 -1
  8. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/tuning.py +3 -0
  9. {drydock_cli-3.0.81 → drydock_cli-3.0.83/drydock_cli.egg-info}/PKG-INFO +46 -1
  10. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock_cli.egg-info/SOURCES.txt +2 -0
  11. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/pyproject.toml +1 -1
  12. drydock_cli-3.0.83/tests/test_advisor.py +83 -0
  13. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/LICENSE +0 -0
  14. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/NOTICE +0 -0
  15. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/__init__.py +0 -0
  16. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/__main__.py +0 -0
  17. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/agent.py +0 -0
  18. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/bash_safety.py +0 -0
  19. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/builtin_skills/__init__.py +0 -0
  20. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/builtin_skills/rmf-categorize.md +0 -0
  21. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/builtin_skills/rmf-control.md +0 -0
  22. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/builtin_skills/rmf-poam.md +0 -0
  23. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/builtin_skills/rmf-review.md +0 -0
  24. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/builtin_skills/stig-assess.md +0 -0
  25. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/builtin_skills/stig-remediate.md +0 -0
  26. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/cci.py +0 -0
  27. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/cli.py +0 -0
  28. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/compaction.py +0 -0
  29. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/detect.py +0 -0
  30. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/extract.py +0 -0
  31. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/gittools.py +0 -0
  32. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/graphrag.py +0 -0
  33. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/guards.py +0 -0
  34. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/loop_detect.py +0 -0
  35. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/mcp.py +0 -0
  36. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/poam.py +0 -0
  37. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/providers.py +0 -0
  38. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/rmf.py +0 -0
  39. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/rmf_graph.py +0 -0
  40. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/skills.py +0 -0
  41. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/stig.py +0 -0
  42. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/tool_registry.py +0 -0
  43. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/tui/__init__.py +0 -0
  44. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/tui/approval.py +0 -0
  45. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/tui/messages.py +0 -0
  46. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock/web.py +0 -0
  47. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock_cli.egg-info/dependency_links.txt +0 -0
  48. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock_cli.egg-info/entry_points.txt +0 -0
  49. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock_cli.egg-info/requires.txt +0 -0
  50. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/drydock_cli.egg-info/top_level.txt +0 -0
  51. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/setup.cfg +0 -0
  52. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_approval.py +0 -0
  53. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_back_command.py +0 -0
  54. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_bash_output_bounding.py +0 -0
  55. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_bash_process_group.py +0 -0
  56. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_bash_safety.py +0 -0
  57. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_bash_timeout_network.py +0 -0
  58. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_cci.py +0 -0
  59. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_cli_agents.py +0 -0
  60. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_compact_command.py +0 -0
  61. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_compaction.py +0 -0
  62. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_config.py +0 -0
  63. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_config_migration.py +0 -0
  64. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_context_limit_config.py +0 -0
  65. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_detect.py +0 -0
  66. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_dispatch.py +0 -0
  67. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_e2e_connected.py +0 -0
  68. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_empty_response.py +0 -0
  69. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_extract.py +0 -0
  70. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_failure_loop.py +0 -0
  71. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_first_run_setup.py +0 -0
  72. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_gittools.py +0 -0
  73. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_graphrag.py +0 -0
  74. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_guards_and_tools.py +0 -0
  75. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_hallucinated_tools.py +0 -0
  76. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_leaked_tool_call.py +0 -0
  77. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_loop_detect.py +0 -0
  78. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_mcp.py +0 -0
  79. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_oneshot_unreachable.py +0 -0
  80. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_plan_autocontinue.py +0 -0
  81. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_poam.py +0 -0
  82. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_providers_unreachable.py +0 -0
  83. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_read_index.py +0 -0
  84. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_rmf.py +0 -0
  85. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_rmf_graph.py +0 -0
  86. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_rmf_stig_graph.py +0 -0
  87. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_runaway_repetition.py +0 -0
  88. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_server_probe.py +0 -0
  89. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_skills.py +0 -0
  90. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_stig.py +0 -0
  91. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_stop.py +0 -0
  92. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_streaming_newlines.py +0 -0
  93. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_subagent.py +0 -0
  94. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_system_prompt_help.py +0 -0
  95. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_todo.py +0 -0
  96. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_tool_arg_parsing.py +0 -0
  97. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_tools_undo.py +0 -0
  98. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_tui.py +0 -0
  99. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_tuning.py +0 -0
  100. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_viewimage.py +0 -0
  101. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_vision_input.py +0 -0
  102. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_web_tools.py +0 -0
  103. {drydock_cli-3.0.81 → drydock_cli-3.0.83}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.81
3
+ Version: 3.0.83
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -72,6 +72,10 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
72
72
  read-only `Knowledge` tool.
73
73
  - **Multi-agent** — `Dispatch` runs several read-only sub-agents in parallel;
74
74
  `task` runs one — each in a fresh context, for focused investigation.
75
+ - **Second-model advisor** — point `/advisor` at a stronger model on any
76
+ OpenAI-compatible endpoint (e.g. Gemini, or a proxy on another box); the agent
77
+ calls the `Consult` tool for a second opinion when stuck, and you can `/ask
78
+ <question>` directly. Opt-in, user-configured — no extra dependency.
75
79
  - **MCP** — connect to Model Context Protocol servers (`~/.drydock/mcp.json`);
76
80
  their tools appear as `mcp__<server>__<tool>`. List them with `/mcp`.
77
81
  - **Skills** — reusable `/<name>` commands authored as markdown in
@@ -103,6 +107,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
103
107
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
104
108
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
105
109
  | `/compact` · `/context [n]` | Shrink context now · view/set the context-window budget |
110
+ | `/advisor` · `/ask <q>` | Set up a 2nd 'advisor' model (Gemini etc.) · consult it |
106
111
  | `/status` · `/clear` · `/help` · `/quit` | Session stats · reset · help · exit |
107
112
 
108
113
  ### Knowledge base (GraphRAG) — ingesting your documents
@@ -133,6 +138,46 @@ drydock-cli[pdf]` (pypdf). The index is a single JSON at
133
138
  Skills are markdown files in `~/.drydock/skills/` (personal) or
134
139
  `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
135
140
 
141
+ ### Second-model advisor (a stronger model for a second opinion)
142
+
143
+ Drydock's primary model is a small local one. You can wire in a **second,
144
+ stronger model** — e.g. **Gemini** — to consult when the local model is stuck or
145
+ you want to sanity-check a design. It's just another OpenAI-compatible endpoint,
146
+ so there's **no extra dependency**; it's **opt-in** and off until you configure
147
+ it (the only call is the one you point it at — consistent with the no-phone-home
148
+ stance).
149
+
150
+ **Configure it** (persists to `~/.drydock/config.toml`):
151
+ ```
152
+ /advisor url http://<other-box>:4000/v1 # any OpenAI-compatible endpoint
153
+ /advisor model gemini-2.5-pro
154
+ /advisor key <api-key> # if the endpoint needs one
155
+ /advisor test # ping it: reachable? which model? latency?
156
+ /advisor # show current config (key masked)
157
+ ```
158
+
159
+ **Use it** two ways:
160
+ - **You:** `/ask <question>` — consults the advisor and shows its answer.
161
+ - **The agent:** it can call the read-only `Consult` tool on its own when it hits
162
+ something hard, passing the relevant code/error as context. You act on the advice.
163
+
164
+ **Pointing it at Gemini** — two options:
165
+ 1. **Gemini's official OpenAI-compatible endpoint** (if this box has internet):
166
+ ```
167
+ /advisor url https://generativelanguage.googleapis.com/v1beta/openai
168
+ /advisor model gemini-2.5-pro
169
+ /advisor key <your-gemini-api-key>
170
+ ```
171
+ 2. **A proxy on another box** (e.g. where your key lives). LiteLLM is one line:
172
+ ```bash
173
+ pip install 'litellm[proxy]'; export GEMINI_API_KEY=...
174
+ litellm --model gemini/gemini-2.5-pro --host 0.0.0.0 --port 4000
175
+ ```
176
+ then `/advisor url http://<that-box-ip>:4000/v1`.
177
+
178
+ Any OpenAI-compatible model works here (another local server, a hosted model,
179
+ etc.) — Gemini is just the common case.
180
+
136
181
  ### RMF automation (NIST SP 800-53)
137
182
 
138
183
  For Risk Management Framework work, Drydock can ingest the NIST SP 800-53 Rev 5
@@ -48,6 +48,10 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
48
48
  read-only `Knowledge` tool.
49
49
  - **Multi-agent** — `Dispatch` runs several read-only sub-agents in parallel;
50
50
  `task` runs one — each in a fresh context, for focused investigation.
51
+ - **Second-model advisor** — point `/advisor` at a stronger model on any
52
+ OpenAI-compatible endpoint (e.g. Gemini, or a proxy on another box); the agent
53
+ calls the `Consult` tool for a second opinion when stuck, and you can `/ask
54
+ <question>` directly. Opt-in, user-configured — no extra dependency.
51
55
  - **MCP** — connect to Model Context Protocol servers (`~/.drydock/mcp.json`);
52
56
  their tools appear as `mcp__<server>__<tool>`. List them with `/mcp`.
53
57
  - **Skills** — reusable `/<name>` commands authored as markdown in
@@ -79,6 +83,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
79
83
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
80
84
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
81
85
  | `/compact` · `/context [n]` | Shrink context now · view/set the context-window budget |
86
+ | `/advisor` · `/ask <q>` | Set up a 2nd 'advisor' model (Gemini etc.) · consult it |
82
87
  | `/status` · `/clear` · `/help` · `/quit` | Session stats · reset · help · exit |
83
88
 
84
89
  ### Knowledge base (GraphRAG) — ingesting your documents
@@ -109,6 +114,46 @@ drydock-cli[pdf]` (pypdf). The index is a single JSON at
109
114
  Skills are markdown files in `~/.drydock/skills/` (personal) or
110
115
  `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
111
116
 
117
+ ### Second-model advisor (a stronger model for a second opinion)
118
+
119
+ Drydock's primary model is a small local one. You can wire in a **second,
120
+ stronger model** — e.g. **Gemini** — to consult when the local model is stuck or
121
+ you want to sanity-check a design. It's just another OpenAI-compatible endpoint,
122
+ so there's **no extra dependency**; it's **opt-in** and off until you configure
123
+ it (the only call is the one you point it at — consistent with the no-phone-home
124
+ stance).
125
+
126
+ **Configure it** (persists to `~/.drydock/config.toml`):
127
+ ```
128
+ /advisor url http://<other-box>:4000/v1 # any OpenAI-compatible endpoint
129
+ /advisor model gemini-2.5-pro
130
+ /advisor key <api-key> # if the endpoint needs one
131
+ /advisor test # ping it: reachable? which model? latency?
132
+ /advisor # show current config (key masked)
133
+ ```
134
+
135
+ **Use it** two ways:
136
+ - **You:** `/ask <question>` — consults the advisor and shows its answer.
137
+ - **The agent:** it can call the read-only `Consult` tool on its own when it hits
138
+ something hard, passing the relevant code/error as context. You act on the advice.
139
+
140
+ **Pointing it at Gemini** — two options:
141
+ 1. **Gemini's official OpenAI-compatible endpoint** (if this box has internet):
142
+ ```
143
+ /advisor url https://generativelanguage.googleapis.com/v1beta/openai
144
+ /advisor model gemini-2.5-pro
145
+ /advisor key <your-gemini-api-key>
146
+ ```
147
+ 2. **A proxy on another box** (e.g. where your key lives). LiteLLM is one line:
148
+ ```bash
149
+ pip install 'litellm[proxy]'; export GEMINI_API_KEY=...
150
+ litellm --model gemini/gemini-2.5-pro --host 0.0.0.0 --port 4000
151
+ ```
152
+ then `/advisor url http://<that-box-ip>:4000/v1`.
153
+
154
+ Any OpenAI-compatible model works here (another local server, a hosted model,
155
+ etc.) — Gemini is just the common case.
156
+
112
157
  ### RMF automation (NIST SP 800-53)
113
158
 
114
159
  For Risk Management Framework work, Drydock can ingest the NIST SP 800-53 Rev 5
@@ -0,0 +1,100 @@
1
+ """Second-model "advisor" — consult a stronger model for a second opinion.
2
+
3
+ Drydock's primary model is a small local one. Sometimes you want a heavier model
4
+ (e.g. Gemini) to sanity-check a design, unstick a hard bug, or review an
5
+ approach. The advisor is just ANOTHER OpenAI-compatible endpoint — Gemini exposes
6
+ one (`.../v1beta/openai/`), and any proxy on another box works too — so this
7
+ reuses the existing `openai` client with no new dependency.
8
+
9
+ It's opt-in and user-configured (empty by default): the only network call is the
10
+ one YOU point it at, consistent with drydock's no-phone-home stance. Exposed as
11
+ the `Consult` tool (the agent asks when stuck) and the `/ask` command (you ask).
12
+
13
+ All logic original to Drydock.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ _ADVISOR_SYSTEM = (
18
+ "You are a senior engineering advisor consulted by another AI coding agent "
19
+ "(Drydock) running a smaller local model. Give concise, direct, expert advice "
20
+ "— the specific fix, the design trade-off, or the likely root cause. Prefer "
21
+ "concrete steps and short code over prose. If the question is under-specified, "
22
+ "state the key assumption and answer anyway. You cannot run tools; the calling "
23
+ "agent will act on your answer."
24
+ )
25
+
26
+
27
+ def is_configured(config: dict) -> bool:
28
+ """True when an advisor endpoint + model are set."""
29
+ return bool((config.get("advisor_base_url") or "").strip()
30
+ and (config.get("advisor_model") or "").strip())
31
+
32
+
33
+ def not_configured_message() -> str:
34
+ return (
35
+ "No advisor (second) model is configured. Point it at any OpenAI-compatible "
36
+ "endpoint — e.g. Gemini's, or a proxy on another box:\n"
37
+ " /advisor url http://<other-box>:<port>/v1\n"
38
+ " /advisor model gemini-2.5-pro\n"
39
+ " /advisor key <api-key> (if the endpoint needs one)\n"
40
+ "Then use /ask <question> or let the agent call the Consult tool."
41
+ )
42
+
43
+
44
+ def _call(question: str, config: dict, *, context: str = "",
45
+ timeout: float = 120.0, max_tokens: int = 2048) -> str:
46
+ """Make one advisor call. RAISES on transport/model error (callers decide how
47
+ to surface it)."""
48
+ import httpx
49
+ from openai import OpenAI
50
+
51
+ base = config["advisor_base_url"].strip()
52
+ model = config["advisor_model"].strip()
53
+ key = (config.get("advisor_api_key") or "").strip() or "dummy"
54
+ messages: list[dict] = [{"role": "system", "content": _ADVISOR_SYSTEM}]
55
+ if context.strip():
56
+ messages.append({"role": "user",
57
+ "content": f"Context from the current task:\n{context.strip()}"})
58
+ messages.append({"role": "user", "content": question})
59
+ kwargs = {"model": model, "messages": messages, "temperature": 0.3, "max_tokens": max_tokens}
60
+ client = OpenAI(api_key=key, base_url=base, max_retries=1,
61
+ timeout=httpx.Timeout(timeout, connect=10.0))
62
+ resp = client.chat.completions.create(**kwargs)
63
+ return (resp.choices[0].message.content or "").strip()
64
+
65
+
66
+ def consult(question: str, config: dict, *, context: str = "", timeout: float = 120.0) -> str:
67
+ """Ask the configured advisor model and return its answer (or a clean error).
68
+ Never raises — a failed consult must not crash the turn."""
69
+ question = (question or "").strip()
70
+ if not question:
71
+ return "Error: nothing to ask the advisor."
72
+ if not is_configured(config):
73
+ return not_configured_message()
74
+ try:
75
+ return _call(question, config, context=context, timeout=timeout) \
76
+ or "(the advisor model returned an empty response)"
77
+ except Exception as e: # noqa: BLE001 — never crash the caller
78
+ return (f"Could not reach the advisor model ({config.get('advisor_model')}) at "
79
+ f"{config.get('advisor_base_url')}: {e}\n"
80
+ "Check /advisor settings and that the endpoint is up.")
81
+
82
+
83
+ def test_connection(config: dict) -> str:
84
+ """Ping the advisor endpoint with a trivial prompt and report reachability +
85
+ latency — one-shot setup confirmation for `/advisor test`."""
86
+ import time
87
+
88
+ if not is_configured(config):
89
+ return not_configured_message()
90
+ model = config.get("advisor_model")
91
+ base = config.get("advisor_base_url")
92
+ t0 = time.monotonic()
93
+ try:
94
+ reply = _call("Reply with exactly: OK", config, timeout=30.0, max_tokens=8)
95
+ except Exception as e: # noqa: BLE001
96
+ return (f"✗ advisor unreachable — {model} at {base}\n {e}\n"
97
+ " Check the endpoint is up, the URL ends in /v1, and the key/model are right.")
98
+ dt = time.monotonic() - t0
99
+ return (f"✓ advisor reachable — {model} at {base} responded in {dt:.1f}s"
100
+ + (f' (said: "{reply[:40]}").' if reply else "."))
@@ -35,6 +35,13 @@ DEFAULTS: dict[str, object] = {
35
35
  # value. Default 65536 matches the bundled gemma4 server (-c 65536).
36
36
  "context_limit": 65536,
37
37
  "theme": "harbor",
38
+ # Optional SECOND model ("advisor") for a stronger second opinion — e.g. a
39
+ # Gemini OpenAI-compatible endpoint on another box. Empty = disabled. The
40
+ # agent can call the `Consult` tool and you can use `/ask`; configure with
41
+ # `/advisor`. It's just another OpenAI-compatible endpoint (no extra deps).
42
+ "advisor_model": "",
43
+ "advisor_base_url": "",
44
+ "advisor_api_key": "",
38
45
  }
39
46
 
40
47
 
@@ -178,6 +178,24 @@ SCHEMAS = [
178
178
  "required": ["tasks"],
179
179
  },
180
180
  },
181
+ {
182
+ "name": "Consult",
183
+ "description": (
184
+ "Ask a SECOND, more powerful advisor model (if the user configured "
185
+ "one, e.g. Gemini) for a second opinion — use when you're stuck, want "
186
+ "to sanity-check a design/approach, or need an expert take on a tricky "
187
+ "bug. It only advises (no tools); YOU act on the answer. Pass the "
188
+ "relevant code/error in `context` so the advisor has what it needs."
189
+ ),
190
+ "input_schema": {
191
+ "type": "object",
192
+ "properties": {
193
+ "question": {"type": "string", "description": "The specific question to ask the advisor."},
194
+ "context": {"type": "string", "description": "Relevant code, error output, or background the advisor needs."},
195
+ },
196
+ "required": ["question"],
197
+ },
198
+ },
181
199
  {
182
200
  "name": "task",
183
201
  "description": (
@@ -1066,6 +1084,16 @@ _SUBAGENT_SYSTEM = (
1066
1084
  )
1067
1085
 
1068
1086
 
1087
+ def tool_consult(params: dict, config: dict) -> str:
1088
+ """Ask the configured second/advisor model (e.g. Gemini) for a second opinion."""
1089
+ from drydock import advisor
1090
+
1091
+ question = (params.get("question") or params.get("prompt") or "").strip()
1092
+ if not question:
1093
+ return "Error: Consult needs a `question`."
1094
+ return advisor.consult(question, config, context=params.get("context", "") or "")
1095
+
1096
+
1069
1097
  # A sub-agent's whole job is to keep its investigation OUT of the main agent's
1070
1098
  # context and hand back only a partition. The system prompt asks for a concise
1071
1099
  # summary, but a runaway model could still return a wall of text — so cap what
@@ -1474,7 +1502,7 @@ def register_all():
1474
1502
  "Write": tool_write, "Edit": tool_edit,
1475
1503
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
1476
1504
  "todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
1477
- "Knowledge": tool_knowledge,
1505
+ "Consult": tool_consult, "Knowledge": tool_knowledge,
1478
1506
  "GraphQuery": tool_graphquery, "GraphAdd": tool_graphadd,
1479
1507
  "StigRules": tool_stigrules, "StigRule": tool_stigrule, "StigSet": tool_stigset,
1480
1508
  "WebSearch": tool_websearch, "WebFetch": tool_webfetch,
@@ -1484,8 +1512,8 @@ def register_all():
1484
1512
  # Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
1485
1513
  # GitCommit + GraphAdd write).
1486
1514
  read_only = name in (
1487
- "Read", "ViewImage", "Glob", "Grep", "task", "Dispatch", "Knowledge",
1488
- "GraphQuery", "StigRules", "StigRule",
1515
+ "Read", "ViewImage", "Glob", "Grep", "task", "Dispatch", "Consult",
1516
+ "Knowledge", "GraphQuery", "StigRules", "StigRule",
1489
1517
  "WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
1490
1518
  )
1491
1519
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
@@ -487,6 +487,10 @@ class DrydockApp(App):
487
487
  self._cmd_compact()
488
488
  elif cmd == "/context":
489
489
  self._cmd_context(arg)
490
+ elif cmd == "/advisor":
491
+ self._cmd_advisor(arg)
492
+ elif cmd == "/ask":
493
+ self._cmd_ask(arg)
490
494
  elif cmd == "/graphrag":
491
495
  self._cmd_graphrag(arg)
492
496
  elif cmd == "/status":
@@ -509,6 +513,7 @@ class DrydockApp(App):
509
513
  " /status session model, cwd, turns, tokens\n"
510
514
  " /compact shrink old context to free up the window\n"
511
515
  " /context view/set the context-window budget (e.g. /context 65536)\n"
516
+ " /advisor set up a 2nd 'advisor' model (e.g. Gemini) · /ask <q> to consult it\n"
512
517
  " /graphrag ingest docs into a knowledge base the agent can use\n"
513
518
  " build <path> · add <path> · query <q> · status · clear\n"
514
519
  " /skills list skills · /skills new <name> <prompt> to create one\n"
@@ -843,6 +848,67 @@ class DrydockApp(App):
843
848
  "so drydock's budget is the effective limit — no server-side cap.")
844
849
  self.call_from_thread(self._info, msg)
845
850
 
851
+ def _cmd_advisor(self, arg: str) -> None:
852
+ """View/set the optional second 'advisor' model (a stronger model, e.g.
853
+ Gemini, on any OpenAI-compatible endpoint). Persists to config.toml."""
854
+ arg = (arg or "").strip()
855
+ if not arg:
856
+ from drydock import advisor
857
+ m = self.config.get("advisor_model") or "(unset)"
858
+ u = self.config.get("advisor_base_url") or "(unset)"
859
+ has_key = "yes" if (self.config.get("advisor_api_key") or "").strip() else "no"
860
+ state = "configured ✓" if advisor.is_configured(self.config) else "not configured"
861
+ self._info(
862
+ f"advisor ({state}):\n"
863
+ f" model: {m}\n"
864
+ f" endpoint: {u}\n"
865
+ f" api key: {has_key}\n"
866
+ "Set up: /advisor url <base_url/v1> · /advisor model <name> · "
867
+ "/advisor key <api_key> · /advisor test\n"
868
+ "Then: /ask <question> (you) or the agent calls the Consult tool."
869
+ )
870
+ return
871
+ parts = arg.split(maxsplit=1)
872
+ sub = parts[0].lower()
873
+ val = parts[1].strip() if len(parts) > 1 else ""
874
+ if sub == "test":
875
+ from drydock import advisor
876
+ if not advisor.is_configured(self.config):
877
+ self._info(advisor.not_configured_message())
878
+ return
879
+ self._info(f"Testing the advisor endpoint ({self.config.get('advisor_base_url')})…")
880
+ self.run_worker(lambda: self.call_from_thread(
881
+ self._info, advisor.test_connection(self.config)), thread=True)
882
+ return
883
+ keymap = {"url": "advisor_base_url", "endpoint": "advisor_base_url",
884
+ "model": "advisor_model", "key": "advisor_api_key", "api_key": "advisor_api_key"}
885
+ if sub not in keymap or not val:
886
+ self._mount(ErrorMessage(
887
+ "usage: /advisor [url <base_url> | model <name> | key <api_key> | test]"))
888
+ return
889
+ self.config[keymap[sub]] = val
890
+ self._persist_config()
891
+ shown = "•••••" if keymap[sub] == "advisor_api_key" else val
892
+ self._info(f"✓ advisor {sub} set to {shown} (saved to config.toml).")
893
+
894
+ def _cmd_ask(self, arg: str) -> None:
895
+ """Ask the second/advisor model directly and show its answer."""
896
+ from drydock import advisor
897
+ q = (arg or "").strip()
898
+ if not q:
899
+ self._info("usage: /ask <question> — consult your configured advisor (second) model")
900
+ return
901
+ if not advisor.is_configured(self.config):
902
+ self._info(advisor.not_configured_message())
903
+ return
904
+ self._info(f"Asking the advisor ({self.config.get('advisor_model')})…")
905
+ self.run_worker(lambda: self._ask_worker(q), thread=True)
906
+
907
+ def _ask_worker(self, question: str) -> None:
908
+ from drydock import advisor
909
+ answer = advisor.consult(question, self.config)
910
+ self.call_from_thread(self._info, f"💡 advisor:\n{answer}")
911
+
846
912
  def _cmd_compact(self) -> None:
847
913
  """Manually compact the conversation to reclaim context NOW, without
848
914
  waiting for the automatic 60%-of-window threshold (agent.maybe_compact).
@@ -153,7 +153,8 @@ class PromptHistory:
153
153
  # completes the prefix.
154
154
  SLASH_COMMANDS = [
155
155
  "/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
156
- "/compact", "/context", "/graphrag", "/skills", "/loop", "/mcp", "/rmf", "/stig",
156
+ "/compact", "/context", "/advisor", "/ask", "/graphrag", "/skills", "/loop",
157
+ "/mcp", "/rmf", "/stig",
157
158
  "/clear", "/quit",
158
159
  ]
159
160
 
@@ -236,6 +236,9 @@ _DRYDOCK_COMMANDS_HELP = (
236
236
  "- Images / vision: the user just references an image file path in their "
237
237
  "message (a .png/.jpg screenshot, mockup, or diagram on disk) and it is "
238
238
  "attached for you to SEE — no command needed.\n"
239
+ "- Second opinion: if the user set up an advisor model (`/advisor`), you can "
240
+ "call the `Consult` tool to ask a stronger model when you're stuck; the user "
241
+ "can also `/ask <question>` themselves.\n"
239
242
  "- Other: `/model` (model/endpoint), `/cwd`, `/undo` (revert last write), "
240
243
  "`/back` (rewind a turn), `/compact` (shrink context), `/context` (view/set "
241
244
  "the context-window budget), `/loop <n> <prompt>` (repeat a prompt), `/mcp` "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.81
3
+ Version: 3.0.83
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -72,6 +72,10 @@ A full agentic CLI harness — every tool below is clean-room and dependency-fre
72
72
  read-only `Knowledge` tool.
73
73
  - **Multi-agent** — `Dispatch` runs several read-only sub-agents in parallel;
74
74
  `task` runs one — each in a fresh context, for focused investigation.
75
+ - **Second-model advisor** — point `/advisor` at a stronger model on any
76
+ OpenAI-compatible endpoint (e.g. Gemini, or a proxy on another box); the agent
77
+ calls the `Consult` tool for a second opinion when stuck, and you can `/ask
78
+ <question>` directly. Opt-in, user-configured — no extra dependency.
75
79
  - **MCP** — connect to Model Context Protocol servers (`~/.drydock/mcp.json`);
76
80
  their tools appear as `mcp__<server>__<tool>`. List them with `/mcp`.
77
81
  - **Skills** — reusable `/<name>` commands authored as markdown in
@@ -103,6 +107,7 @@ Typed into the prompt. The agent also knows these, so you can just **ask it**
103
107
  | `/model` · `/cwd` | Show/set model & endpoint · working directory |
104
108
  | `/undo` · `/back` | Revert the last write · rewind the last turn |
105
109
  | `/compact` · `/context [n]` | Shrink context now · view/set the context-window budget |
110
+ | `/advisor` · `/ask <q>` | Set up a 2nd 'advisor' model (Gemini etc.) · consult it |
106
111
  | `/status` · `/clear` · `/help` · `/quit` | Session stats · reset · help · exit |
107
112
 
108
113
  ### Knowledge base (GraphRAG) — ingesting your documents
@@ -133,6 +138,46 @@ drydock-cli[pdf]` (pypdf). The index is a single JSON at
133
138
  Skills are markdown files in `~/.drydock/skills/` (personal) or
134
139
  `<project>/.drydock/skills/` (project); `/skills new` writes one for you.
135
140
 
141
+ ### Second-model advisor (a stronger model for a second opinion)
142
+
143
+ Drydock's primary model is a small local one. You can wire in a **second,
144
+ stronger model** — e.g. **Gemini** — to consult when the local model is stuck or
145
+ you want to sanity-check a design. It's just another OpenAI-compatible endpoint,
146
+ so there's **no extra dependency**; it's **opt-in** and off until you configure
147
+ it (the only call is the one you point it at — consistent with the no-phone-home
148
+ stance).
149
+
150
+ **Configure it** (persists to `~/.drydock/config.toml`):
151
+ ```
152
+ /advisor url http://<other-box>:4000/v1 # any OpenAI-compatible endpoint
153
+ /advisor model gemini-2.5-pro
154
+ /advisor key <api-key> # if the endpoint needs one
155
+ /advisor test # ping it: reachable? which model? latency?
156
+ /advisor # show current config (key masked)
157
+ ```
158
+
159
+ **Use it** two ways:
160
+ - **You:** `/ask <question>` — consults the advisor and shows its answer.
161
+ - **The agent:** it can call the read-only `Consult` tool on its own when it hits
162
+ something hard, passing the relevant code/error as context. You act on the advice.
163
+
164
+ **Pointing it at Gemini** — two options:
165
+ 1. **Gemini's official OpenAI-compatible endpoint** (if this box has internet):
166
+ ```
167
+ /advisor url https://generativelanguage.googleapis.com/v1beta/openai
168
+ /advisor model gemini-2.5-pro
169
+ /advisor key <your-gemini-api-key>
170
+ ```
171
+ 2. **A proxy on another box** (e.g. where your key lives). LiteLLM is one line:
172
+ ```bash
173
+ pip install 'litellm[proxy]'; export GEMINI_API_KEY=...
174
+ litellm --model gemini/gemini-2.5-pro --host 0.0.0.0 --port 4000
175
+ ```
176
+ then `/advisor url http://<that-box-ip>:4000/v1`.
177
+
178
+ Any OpenAI-compatible model works here (another local server, a hosted model,
179
+ etc.) — Gemini is just the common case.
180
+
136
181
  ### RMF automation (NIST SP 800-53)
137
182
 
138
183
  For Risk Management Framework work, Drydock can ingest the NIST SP 800-53 Rev 5
@@ -4,6 +4,7 @@ README.md
4
4
  pyproject.toml
5
5
  drydock/__init__.py
6
6
  drydock/__main__.py
7
+ drydock/advisor.py
7
8
  drydock/agent.py
8
9
  drydock/bash_safety.py
9
10
  drydock/cci.py
@@ -45,6 +46,7 @@ drydock_cli.egg-info/dependency_links.txt
45
46
  drydock_cli.egg-info/entry_points.txt
46
47
  drydock_cli.egg-info/requires.txt
47
48
  drydock_cli.egg-info/top_level.txt
49
+ tests/test_advisor.py
48
50
  tests/test_approval.py
49
51
  tests/test_back_command.py
50
52
  tests/test_bash_output_bounding.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.81"
10
+ version = "3.0.83"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,83 @@
1
+ """Second-model advisor: consult() + the Consult tool + config."""
2
+ from __future__ import annotations
3
+
4
+ import drydock.tools # noqa: F401 — registers tools
5
+ from drydock import advisor
6
+ from drydock import tool_registry as reg
7
+
8
+
9
+ def test_is_configured():
10
+ assert not advisor.is_configured({})
11
+ assert not advisor.is_configured({"advisor_base_url": "http://x/v1"}) # no model
12
+ assert advisor.is_configured({"advisor_base_url": "http://x/v1", "advisor_model": "g"})
13
+
14
+
15
+ def test_consult_unconfigured_gives_setup_help():
16
+ out = advisor.consult("anything", {})
17
+ assert "No advisor" in out and "/advisor" in out
18
+
19
+
20
+ def test_consult_empty_question():
21
+ assert "nothing to ask" in advisor.consult(" ", {"advisor_base_url": "http://x/v1", "advisor_model": "g"})
22
+
23
+
24
+ def test_consult_calls_endpoint_and_returns_answer(monkeypatch):
25
+ captured = {}
26
+
27
+ class _Msg: # noqa: D401
28
+ content = "Use a mutex."
29
+
30
+ class _Choice:
31
+ message = _Msg()
32
+
33
+ class _Resp:
34
+ choices = [_Choice()]
35
+
36
+ class _FakeClient:
37
+ def __init__(self, **kw): captured["client"] = kw
38
+ class chat: # noqa: N801
39
+ class completions: # noqa: N801
40
+ @staticmethod
41
+ def create(**kw):
42
+ captured["call"] = kw
43
+ return _Resp()
44
+
45
+ monkeypatch.setattr(advisor, "OpenAI", _FakeClient, raising=False)
46
+ # advisor imports OpenAI lazily; patch the module symbol it will bind
47
+ import openai
48
+ monkeypatch.setattr(openai, "OpenAI", _FakeClient)
49
+ cfg = {"advisor_base_url": "http://box:9/v1", "advisor_model": "gemini-x", "advisor_api_key": "k"}
50
+ out = advisor.consult("How to fix this race?", cfg, context="def f(): ...")
51
+ assert out == "Use a mutex."
52
+ assert captured["client"]["base_url"] == "http://box:9/v1"
53
+ assert captured["call"]["model"] == "gemini-x"
54
+ # system + context + question all present
55
+ roles = [m["role"] for m in captured["call"]["messages"]]
56
+ assert roles == ["system", "user", "user"]
57
+
58
+
59
+ def test_consult_tool_unconfigured():
60
+ out = reg.execute("Consult", {"question": "hi"}, {})
61
+ assert "No advisor" in out
62
+ assert "needs a `question`" in reg.execute("Consult", {}, {})
63
+
64
+
65
+ def test_consult_tool_is_read_only():
66
+ assert reg.get("Consult").read_only is True
67
+
68
+
69
+ def test_test_connection_success(monkeypatch):
70
+ monkeypatch.setattr(advisor, "_call", lambda *a, **k: "OK")
71
+ out = advisor.test_connection({"advisor_base_url": "http://b/v1", "advisor_model": "m"})
72
+ assert out.startswith("✓") and "m" in out and "responded" in out
73
+
74
+
75
+ def test_test_connection_failure(monkeypatch):
76
+ def boom(*a, **k): raise OSError("connection refused")
77
+ monkeypatch.setattr(advisor, "_call", boom)
78
+ out = advisor.test_connection({"advisor_base_url": "http://b/v1", "advisor_model": "m"})
79
+ assert out.startswith("✗") and "unreachable" in out and "connection refused" in out
80
+
81
+
82
+ def test_test_connection_unconfigured():
83
+ assert "No advisor" in advisor.test_connection({})
File without changes
File without changes
File without changes