drydock-cli 3.0.80__tar.gz → 3.0.82__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.
- {drydock_cli-3.0.80/drydock_cli.egg-info → drydock_cli-3.0.82}/PKG-INFO +6 -1
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/README.md +5 -0
- drydock_cli-3.0.82/drydock/advisor.py +73 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/agent.py +12 -1
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/compaction.py +10 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/config.py +7 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/tools/__init__.py +31 -3
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/tui/app.py +56 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/tui/widgets.py +2 -1
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/tuning.py +3 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82/drydock_cli.egg-info}/PKG-INFO +6 -1
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock_cli.egg-info/SOURCES.txt +2 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/pyproject.toml +1 -1
- drydock_cli-3.0.82/tests/test_advisor.py +66 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_cci.py +16 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_poam.py +30 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_server_probe.py +18 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_viewimage.py +8 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/LICENSE +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/NOTICE +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/builtin_skills/__init__.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/builtin_skills/rmf-categorize.md +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/builtin_skills/rmf-control.md +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/builtin_skills/rmf-poam.md +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/builtin_skills/rmf-review.md +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/builtin_skills/stig-assess.md +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/builtin_skills/stig-remediate.md +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/cci.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/cli.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/detect.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/extract.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/guards.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/mcp.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/poam.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/providers.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/rmf.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/rmf_graph.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/skills.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/stig.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock/web.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/setup.cfg +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_bash_output_bounding.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_config.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_dispatch.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_e2e_connected.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_extract.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_mcp.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_read_index.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_rmf.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_rmf_graph.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_rmf_stig_graph.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_stig.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_system_prompt_help.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/tests/test_web_tools.py +0 -0
- {drydock_cli-3.0.80 → drydock_cli-3.0.82}/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.
|
|
3
|
+
Version: 3.0.82
|
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,73 @@
|
|
|
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 consult(question: str, config: dict, *, context: str = "", timeout: float = 120.0) -> str:
|
|
45
|
+
"""Ask the configured advisor model and return its answer (or a clean error).
|
|
46
|
+
Never raises — a failed consult must not crash the turn."""
|
|
47
|
+
question = (question or "").strip()
|
|
48
|
+
if not question:
|
|
49
|
+
return "Error: nothing to ask the advisor."
|
|
50
|
+
if not is_configured(config):
|
|
51
|
+
return not_configured_message()
|
|
52
|
+
|
|
53
|
+
import httpx
|
|
54
|
+
from openai import OpenAI
|
|
55
|
+
|
|
56
|
+
base = config["advisor_base_url"].strip()
|
|
57
|
+
model = config["advisor_model"].strip()
|
|
58
|
+
key = (config.get("advisor_api_key") or "").strip() or "dummy"
|
|
59
|
+
messages: list[dict] = [{"role": "system", "content": _ADVISOR_SYSTEM}]
|
|
60
|
+
if context.strip():
|
|
61
|
+
messages.append({"role": "user",
|
|
62
|
+
"content": f"Context from the current task:\n{context.strip()}"})
|
|
63
|
+
messages.append({"role": "user", "content": question})
|
|
64
|
+
kwargs = {"model": model, "messages": messages, "temperature": 0.3, "max_tokens": 2048}
|
|
65
|
+
try:
|
|
66
|
+
client = OpenAI(api_key=key, base_url=base, max_retries=1,
|
|
67
|
+
timeout=httpx.Timeout(timeout, connect=10.0))
|
|
68
|
+
resp = client.chat.completions.create(**kwargs)
|
|
69
|
+
answer = (resp.choices[0].message.content or "").strip()
|
|
70
|
+
return answer or "(the advisor model returned an empty response)"
|
|
71
|
+
except Exception as e: # noqa: BLE001 — never crash the caller
|
|
72
|
+
return (f"Could not reach the advisor model ({model}) at {base}: {e}\n"
|
|
73
|
+
"Check /advisor settings and that the endpoint is up.")
|
|
@@ -29,7 +29,9 @@ from drydock.tools import register_all
|
|
|
29
29
|
# emits tool calls as TEXT — which is exactly how the empty-registry regression
|
|
30
30
|
# manifested. register_all() is idempotent.
|
|
31
31
|
register_all()
|
|
32
|
-
from drydock.compaction import
|
|
32
|
+
from drydock.compaction import (
|
|
33
|
+
maybe_compact, emergency_compact, is_context_length_error, is_image_load_error,
|
|
34
|
+
)
|
|
33
35
|
from drydock.loop_detect import LoopTracker
|
|
34
36
|
from drydock.tuning import (
|
|
35
37
|
filter_tool_schemas,
|
|
@@ -184,6 +186,15 @@ def run(
|
|
|
184
186
|
if retries >= 2:
|
|
185
187
|
raise
|
|
186
188
|
yield TextChunk("\n[context limit hit — compacting and retrying...]\n")
|
|
189
|
+
elif is_image_load_error(err):
|
|
190
|
+
# Corrupt/truncated/unsupported image the server couldn't decode —
|
|
191
|
+
# end the turn cleanly rather than dumping the raw 400.
|
|
192
|
+
yield TextChunk(
|
|
193
|
+
"\n[The model server couldn't load an attached image — it may be "
|
|
194
|
+
"corrupt, truncated, or an unsupported format. Try a different image.]\n"
|
|
195
|
+
)
|
|
196
|
+
assistant_turn = None
|
|
197
|
+
break
|
|
187
198
|
else:
|
|
188
199
|
raise
|
|
189
200
|
|
|
@@ -34,6 +34,16 @@ def is_context_length_error(err: str) -> bool:
|
|
|
34
34
|
return "context" in e and ("exceed" in e or "too long" in e or "too large" in e)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def is_image_load_error(err: str) -> bool:
|
|
38
|
+
"""Whether a provider 400 is the server failing to decode an attached image
|
|
39
|
+
(corrupt / truncated / unsupported), so the agent ends the turn with a clean
|
|
40
|
+
message instead of dumping the raw API error."""
|
|
41
|
+
e = (err or "").lower()
|
|
42
|
+
return "failed to load image" in e or (
|
|
43
|
+
"image" in e and ("invalid_request" in e or "could not" in e or "decode" in e)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
37
47
|
def estimate_tokens(messages: list) -> int:
|
|
38
48
|
"""Rough token estimate: chars / 3.5.
|
|
39
49
|
|
|
@@ -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", "
|
|
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,57 @@ 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>\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
|
+
keymap = {"url": "advisor_base_url", "endpoint": "advisor_base_url",
|
|
875
|
+
"model": "advisor_model", "key": "advisor_api_key", "api_key": "advisor_api_key"}
|
|
876
|
+
if sub not in keymap or not val:
|
|
877
|
+
self._mount(ErrorMessage("usage: /advisor [url <base_url> | model <name> | key <api_key>]"))
|
|
878
|
+
return
|
|
879
|
+
self.config[keymap[sub]] = val
|
|
880
|
+
self._persist_config()
|
|
881
|
+
shown = "•••••" if keymap[sub] == "advisor_api_key" else val
|
|
882
|
+
self._info(f"✓ advisor {sub} set to {shown} (saved to config.toml).")
|
|
883
|
+
|
|
884
|
+
def _cmd_ask(self, arg: str) -> None:
|
|
885
|
+
"""Ask the second/advisor model directly and show its answer."""
|
|
886
|
+
from drydock import advisor
|
|
887
|
+
q = (arg or "").strip()
|
|
888
|
+
if not q:
|
|
889
|
+
self._info("usage: /ask <question> — consult your configured advisor (second) model")
|
|
890
|
+
return
|
|
891
|
+
if not advisor.is_configured(self.config):
|
|
892
|
+
self._info(advisor.not_configured_message())
|
|
893
|
+
return
|
|
894
|
+
self._info(f"Asking the advisor ({self.config.get('advisor_model')})…")
|
|
895
|
+
self.run_worker(lambda: self._ask_worker(q), thread=True)
|
|
896
|
+
|
|
897
|
+
def _ask_worker(self, question: str) -> None:
|
|
898
|
+
from drydock import advisor
|
|
899
|
+
answer = advisor.consult(question, self.config)
|
|
900
|
+
self.call_from_thread(self._info, f"💡 advisor:\n{answer}")
|
|
901
|
+
|
|
846
902
|
def _cmd_compact(self) -> None:
|
|
847
903
|
"""Manually compact the conversation to reclaim context NOW, without
|
|
848
904
|
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", "/
|
|
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.
|
|
3
|
+
Version: 3.0.82
|
|
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
|
|
@@ -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.
|
|
10
|
+
version = "3.0.82"
|
|
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,66 @@
|
|
|
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
|
|
@@ -80,3 +80,19 @@ def test_load_map_creates_dir_and_caches(tmp_path, monkeypatch):
|
|
|
80
80
|
m = cci.load_map(str(tmp_path))
|
|
81
81
|
assert m["CCI-000366"] == "cm-6" # parsed from the "fetched" list
|
|
82
82
|
assert cci.cache_path(str(tmp_path)).exists() # cached as json
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_parse_cci_tolerates_malformed_items(tmp_path):
|
|
86
|
+
# items with no references, empty references, and non-NIST refs are skipped,
|
|
87
|
+
# not crashed on
|
|
88
|
+
xml = ('<?xml version="1.0"?><cci_list><cci_items>'
|
|
89
|
+
'<cci_item id="CCI-A"></cci_item>' # no references
|
|
90
|
+
'<cci_item id="CCI-B"><references></references></cci_item>'
|
|
91
|
+
'<cci_item id="CCI-C"><references>'
|
|
92
|
+
'<reference title="Some Other Doc" version="1" index="X-9"/></references></cci_item>'
|
|
93
|
+
'<cci_item id="CCI-D"><references>'
|
|
94
|
+
'<reference title="NIST SP 800-53 Revision 5" version="5" index="SI-4"/></references></cci_item>'
|
|
95
|
+
'</cci_items></cci_list>')
|
|
96
|
+
p = tmp_path / "c.xml"; p.write_text(xml)
|
|
97
|
+
m = cci.parse_cci_list(p)
|
|
98
|
+
assert m == {"CCI-D": "si-4"} # only the valid mapping
|
|
@@ -48,3 +48,33 @@ def test_write_csv_has_emass_headers(tmp_path):
|
|
|
48
48
|
assert reader.fieldnames == poam.EMASS_HEADERS
|
|
49
49
|
row = next(reader)
|
|
50
50
|
assert row["Control"] == "CM-6" and row["Severity"] == "High"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_poam_cklb_mixed_severities(tmp_path):
|
|
54
|
+
import json
|
|
55
|
+
data = {"stigs": [{"stig_name": "S", "version": "1", "rules": [
|
|
56
|
+
{"group_id": "V-10", "rule_id": "SV-10", "rule_title": "low one", "severity": "low",
|
|
57
|
+
"status": "open", "fix_text": "fix low", "ccis": ["CCI-1"]},
|
|
58
|
+
{"group_id": "V-11", "rule_id": "SV-11", "rule_title": "med one", "severity": "medium",
|
|
59
|
+
"status": "open", "fix_text": "fix med"},
|
|
60
|
+
{"group_id": "V-12", "rule_id": "SV-12", "rule_title": "clean", "severity": "high",
|
|
61
|
+
"status": "not_a_finding"},
|
|
62
|
+
]}]}
|
|
63
|
+
p = tmp_path / "m.cklb"; p.write_text(json.dumps(data))
|
|
64
|
+
cl = stig.load(p)
|
|
65
|
+
rows = poam.poam_rows(cl, {"CCI-1": "ac-2"})
|
|
66
|
+
assert len(rows) == 2 # 2 open, the NAF excluded
|
|
67
|
+
by_sev = {r["Source Identifying Vulnerability"]: r["Severity"] for r in rows}
|
|
68
|
+
assert by_sev["SV-10"] == "Low" and by_sev["SV-11"] == "Moderate"
|
|
69
|
+
assert rows[0]["Control"] == "AC-2" # CCI-1 mapped
|
|
70
|
+
assert rows[1]["Control"] == "(no CCI)" # no cci on V-11
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_poam_empty_checklist_writes_header_only(tmp_path):
|
|
74
|
+
import json
|
|
75
|
+
p = tmp_path / "e.cklb"; p.write_text(json.dumps({"stigs": [{"rules": []}]}))
|
|
76
|
+
cl = stig.load(p)
|
|
77
|
+
out = tmp_path / "e.csv"
|
|
78
|
+
assert poam.export(cl, {}, out)["rows"] == 0
|
|
79
|
+
lines = out.read_text().strip().splitlines()
|
|
80
|
+
assert len(lines) == 1 and lines[0].startswith("Control,") # header only, valid CSV
|
|
@@ -37,3 +37,21 @@ def test_probe_unreachable_is_none(monkeypatch):
|
|
|
37
37
|
def boom(url, timeout=0): raise OSError("offline")
|
|
38
38
|
monkeypatch.setattr(urllib.request, "urlopen", boom)
|
|
39
39
|
assert providers.probe_server_context("http://localhost:9999/v1") is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_probe_garbage_and_missing_fields(monkeypatch):
|
|
43
|
+
import urllib.request
|
|
44
|
+
def fake(url, timeout=0):
|
|
45
|
+
return _Resp({"unexpected": "shape"}) # no n_ctx / max_model_len
|
|
46
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake)
|
|
47
|
+
assert providers.probe_server_context("http://localhost:8000/v1") is None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_probe_zero_or_negative_ignored(monkeypatch):
|
|
51
|
+
import urllib.request
|
|
52
|
+
def fake(url, timeout=0):
|
|
53
|
+
if url.endswith("/props"):
|
|
54
|
+
return _Resp({"default_generation_settings": {"n_ctx": 0}})
|
|
55
|
+
return _Resp({"data": [{"max_model_len": -1}]})
|
|
56
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake)
|
|
57
|
+
assert providers.probe_server_context("http://localhost:8000/v1") is None
|
|
@@ -66,3 +66,11 @@ def test_read_on_image_points_to_viewimage(tmp_path):
|
|
|
66
66
|
(tmp_path / "f.txt").write_text("hello\nworld\n")
|
|
67
67
|
txt = reg.execute("Read", {"file_path": str(tmp_path / "f.txt")}, {"cwd": str(tmp_path)})
|
|
68
68
|
assert "hello" in txt and "ViewImage" not in txt
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_image_load_error_classifier():
|
|
72
|
+
from drydock.compaction import is_image_load_error
|
|
73
|
+
assert is_image_load_error("Error code: 400 - {'error': {'message': 'Failed to load image or audio file'}}")
|
|
74
|
+
assert is_image_load_error("invalid_request_error: could not decode image")
|
|
75
|
+
assert not is_image_load_error("rate limit exceeded")
|
|
76
|
+
assert not is_image_load_error("context_length_exceeded")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|