security agent cleanup
This commit is contained in:
@@ -4,16 +4,16 @@ Companion code for the [ruxu.dev](https://www.ruxu.dev) blog post series on buil
|
|||||||
|
|
||||||
## Blog Post Series
|
## Blog Post Series
|
||||||
|
|
||||||
1. [Build a Basic AI Agent](https://www.ruxu.dev/articles/ai/build-a-basic-ai-agent/) — A minimal conversational agent with a message loop, powered by a local model via Ollama.
|
1. [Build a Basic AI Agent](https://www.ruxu.dev/articles/ai/build-a-basic-ai-agent/): A minimal conversational agent with a message loop, powered by a local model via Ollama.
|
||||||
2. [Build an AI Agent with Tools](https://www.ruxu.dev/articles/ai/build-an-ai-agent-with-tools/) — Extends the agent with a tool registry so the LLM can read/write files, search the filesystem, run shell commands, and fetch web pages.
|
2. [Build an AI Agent with Tools](https://www.ruxu.dev/articles/ai/build-an-ai-agent-with-tools/): Extends the agent with a tool registry so the LLM can read/write files, search the filesystem, run shell commands, and fetch web pages.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
simple-agent/ # Part 1 — bare-bones agent loop
|
simple-agent/ # Part 1: bare-bones agent loop
|
||||||
agent.py
|
agent.py
|
||||||
|
|
||||||
agent-with-tools/ # Part 2 — agent with tool-calling support
|
agent-with-tools/ # Part 2: agent with tool-calling support
|
||||||
agent.py
|
agent.py
|
||||||
tools/
|
tools/
|
||||||
filesystem.py # read, write, search files
|
filesystem.py # read, write, search files
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def _ask_permission(tool_name: str, args: dict) -> bool:
|
|||||||
try:
|
try:
|
||||||
answer = input(" Allow this action? [y/n]: ").strip().lower()
|
answer = input(" Allow this action? [y/n]: ").strip().lower()
|
||||||
except EOFError:
|
except EOFError:
|
||||||
print(" (EOF — denying permission)")
|
print(" (EOF, denying permission)")
|
||||||
return False
|
return False
|
||||||
if answer in ("y", "yes"):
|
if answer in ("y", "yes"):
|
||||||
return True
|
return True
|
||||||
@@ -107,7 +107,7 @@ def check_permission(
|
|||||||
if mode == PermissionMode.ACCEPT_EDITS and tool_name in WRITE_TOOLS:
|
if mode == PermissionMode.ACCEPT_EDITS and tool_name in WRITE_TOOLS:
|
||||||
path = _resolve_tool_path(tool_name, args)
|
path = _resolve_tool_path(tool_name, args)
|
||||||
if path and _is_within_working_dir(path, working_dir):
|
if path and _is_within_working_dir(path, working_dir):
|
||||||
return True # auto-approved — within the working directory
|
return True # auto-approved, within the working directory
|
||||||
# Path is outside the working directory → fall through to ask
|
# Path is outside the working directory → fall through to ask
|
||||||
|
|
||||||
# Default mode, or acceptEdits for non-write / out-of-tree tools
|
# Default mode, or acceptEdits for non-write / out-of-tree tools
|
||||||
@@ -186,7 +186,7 @@ def agent_loop(client, mode: PermissionMode, working_dir: Path):
|
|||||||
"- Clarification (ask_question): ask the user a single focused question when you "
|
"- Clarification (ask_question): ask the user a single focused question when you "
|
||||||
"are genuinely blocked and cannot reasonably infer the missing information from "
|
"are genuinely blocked and cannot reasonably infer the missing information from "
|
||||||
"context. Do not use it for progress updates or to confirm actions you can already "
|
"context. Do not use it for progress updates or to confirm actions you can already "
|
||||||
"take — only ask when it is strictly necessary to proceed.\n\n"
|
"take, only ask when it is strictly necessary to proceed.\n\n"
|
||||||
|
|
||||||
"## Working directory\n\n"
|
"## Working directory\n\n"
|
||||||
"The current working directory is always the user's project root. "
|
"The current working directory is always the user's project root. "
|
||||||
@@ -202,22 +202,22 @@ def agent_loop(client, mode: PermissionMode, working_dir: Path):
|
|||||||
"todo_append (status: pending).\n"
|
"todo_append (status: pending).\n"
|
||||||
"3. Before starting a step, mark it in_progress with todo_update. "
|
"3. Before starting a step, mark it in_progress with todo_update. "
|
||||||
"Keep only one item in_progress at a time.\n"
|
"Keep only one item in_progress at a time.\n"
|
||||||
"4. Mark items done immediately after completing them — do not batch completions.\n"
|
"4. Mark items done immediately after completing them, do not batch completions.\n"
|
||||||
"5. Call todo_list to review remaining work before moving to the next step.\n"
|
"5. Call todo_list to review remaining work before moving to the next step.\n"
|
||||||
"6. Mark tasks cancelled if they become unnecessary.\n\n"
|
"6. Mark tasks cancelled if they become unnecessary.\n\n"
|
||||||
"For simple, single-step tasks: act directly without creating todos.\n\n"
|
"For simple, single-step tasks: act directly without creating todos.\n\n"
|
||||||
"Planning tool calls (write_scratchpad, todo_append, todo_update, todo_list) "
|
"Planning tool calls (write_scratchpad, todo_append, todo_update, todo_list) "
|
||||||
"are internal bookkeeping, not responses to the user. After any planning tool "
|
"are internal bookkeeping, not responses to the user. After any planning tool "
|
||||||
"call, always continue working immediately — make your next tool call or, once "
|
"call, always continue working immediately, make your next tool call or, once "
|
||||||
"the task is fully complete, give a substantive final answer. "
|
"the task is fully complete, give a substantive final answer. "
|
||||||
"Never emit an empty or whitespace-only message.\n\n"
|
"Never emit an empty or whitespace-only message.\n\n"
|
||||||
"## Replanning\n\n"
|
"## Replanning\n\n"
|
||||||
"After every tool result, check whether the outcome matched your expectation. "
|
"After every tool result, check whether the outcome matched your expectation. "
|
||||||
"If a tool returns an error, unexpected output, or reveals information that "
|
"If a tool returns an error, unexpected output, or reveals information that "
|
||||||
"changes your understanding of the task, do not move to the next planned step — "
|
"changes your understanding of the task, do not move to the next planned step, "
|
||||||
"replan first.\n\n"
|
"replan first.\n\n"
|
||||||
"When a step fails:\n"
|
"When a step fails:\n"
|
||||||
"1. Diagnose in the scratchpad — is this a recoverable input error (wrong path, "
|
"1. Diagnose in the scratchpad: is this a recoverable input error (wrong path, "
|
||||||
"typo, wrong argument) or a deeper problem (wrong approach, wrong assumption)?\n"
|
"typo, wrong argument) or a deeper problem (wrong approach, wrong assumption)?\n"
|
||||||
"2. Mark the task failed: todo_update(id, status='failed').\n"
|
"2. Mark the task failed: todo_update(id, status='failed').\n"
|
||||||
"3. Choose a recovery action:\n"
|
"3. Choose a recovery action:\n"
|
||||||
@@ -227,8 +227,8 @@ def agent_loop(client, mode: PermissionMode, working_dir: Path):
|
|||||||
" - Reorder: new information makes a different task more urgent. Update the "
|
" - Reorder: new information makes a different task more urgent. Update the "
|
||||||
"pending items before continuing.\n"
|
"pending items before continuing.\n"
|
||||||
"4. If todo_update reports that the retry limit has been reached, stop retrying. "
|
"4. If todo_update reports that the retry limit has been reached, stop retrying. "
|
||||||
"Write a clear diagnosis in the scratchpad — what you tried, what failed each "
|
"Write a clear diagnosis in the scratchpad: what you tried, what failed each "
|
||||||
"time, and what you need — then give the user a concise escalation message "
|
"time, and what you need, then give the user a concise escalation message "
|
||||||
"and wait for their input.\n\n"
|
"and wait for their input.\n\n"
|
||||||
"When a tool succeeds but returns information that changes the picture, pause "
|
"When a tool succeeds but returns information that changes the picture, pause "
|
||||||
"before acting. Call todo_list, reassess all pending items in the scratchpad, "
|
"before acting. Call todo_list, reassess all pending items in the scratchpad, "
|
||||||
@@ -236,34 +236,34 @@ def agent_loop(client, mode: PermissionMode, working_dir: Path):
|
|||||||
"## How to use the scratchpad\n\n"
|
"## How to use the scratchpad\n\n"
|
||||||
"Before each tool call during a complex task, update the scratchpad with your "
|
"Before each tool call during a complex task, update the scratchpad with your "
|
||||||
"current thinking. Structure each entry around these five steps:\n\n"
|
"current thinking. Structure each entry around these five steps:\n\n"
|
||||||
"1. Restate the goal — write what you understand the task to be, in your own words. "
|
"1. Restate the goal: write what you understand the task to be, in your own words. "
|
||||||
"This catches misreads before they compound into wasted work.\n"
|
"This catches misreads before they compound into wasted work.\n"
|
||||||
"2. Survey what you know — note which files you have seen, what the code structure "
|
"2. Survey what you know: note which files you have seen, what the code structure "
|
||||||
"looks like, and what constraints or requirements apply.\n"
|
"looks like, and what constraints or requirements apply.\n"
|
||||||
"3. Evaluate options — reason through at least two approaches and explain why you "
|
"3. Evaluate options: reason through at least two approaches and explain why you "
|
||||||
"are choosing one over the other (e.g. 'I could rewrite the middleware, or wrap it. "
|
"are choosing one over the other (e.g. 'I could rewrite the middleware, or wrap it. "
|
||||||
"Wrapping is safer because it leaves the existing call sites untouched.').\n"
|
"Wrapping is safer because it leaves the existing call sites untouched.').\n"
|
||||||
"4. Anticipate failure modes — write down what could go wrong with the chosen "
|
"4. Anticipate failure modes: write down what could go wrong with the chosen "
|
||||||
"approach and how you would diagnose it (e.g. 'If the tests fail after this, the "
|
"approach and how you would diagnose it (e.g. 'If the tests fail after this, the "
|
||||||
"most likely cause is that the session cookie name changed.').\n"
|
"most likely cause is that the session cookie name changed.').\n"
|
||||||
"5. Decide the next single action — commit to exactly one tool call. "
|
"5. Decide the next single action: commit to exactly one tool call. "
|
||||||
"Do not plan several calls at once; decide the next step only.\n\n"
|
"Do not plan several calls at once; decide the next step only.\n\n"
|
||||||
"Re-read the scratchpad whenever you resume after a tool result to keep your "
|
"Re-read the scratchpad whenever you resume after a tool result to keep your "
|
||||||
"reasoning grounded in what you have already learned.\n\n"
|
"reasoning grounded in what you have already learned.\n\n"
|
||||||
"## Done detection\n\n"
|
"## Done detection\n\n"
|
||||||
"Do not give a final answer based on the task list being empty alone. "
|
"Do not give a final answer based on the task list being empty alone. "
|
||||||
"Before declaring the task complete, verify all three of the following:\n\n"
|
"Before declaring the task complete, verify all three of the following:\n\n"
|
||||||
"1. Structural completion — call todo_list and confirm there are no pending, "
|
"1. Structural completion: call todo_list and confirm there are no pending, "
|
||||||
"in_progress, or failed items.\n"
|
"in_progress, or failed items.\n"
|
||||||
"2. Verification — check the output against the original goal. For code tasks: "
|
"2. Verification: check the output against the original goal. For code tasks: "
|
||||||
"run the tests or build with run_bash and confirm they pass. For research tasks: "
|
"run the tests or build with run_bash and confirm they pass. For research tasks: "
|
||||||
"re-read the scratchpad and confirm the assembled answer addresses what was "
|
"re-read the scratchpad and confirm the assembled answer addresses what was "
|
||||||
"actually asked.\n"
|
"actually asked.\n"
|
||||||
"3. Uncertainty check — read the scratchpad and ask: are there unresolved "
|
"3. Uncertainty check: read the scratchpad and ask: are there unresolved "
|
||||||
"questions, assumptions that were never validated, or tasks that were cancelled "
|
"questions, assumptions that were never validated, or tasks that were cancelled "
|
||||||
"rather than properly completed?\n\n"
|
"rather than properly completed?\n\n"
|
||||||
"If all three are satisfied, give your final answer. If any are not, re-enter "
|
"If all three are satisfied, give your final answer. If any are not, re-enter "
|
||||||
"the planning loop — add the outstanding items to the todo list and continue."
|
"the planning loop, add the outstanding items to the todo list and continue."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -290,10 +290,10 @@ def agent_loop(client, mode: PermissionMode, working_dir: Path):
|
|||||||
messages.append(message)
|
messages.append(message)
|
||||||
|
|
||||||
if message.tool_calls:
|
if message.tool_calls:
|
||||||
# The LLM wants to use one or more tools — run them, then loop
|
# The LLM wants to use one or more tools, run them, then loop
|
||||||
handle_tool_calls(message.tool_calls, messages, mode, working_dir)
|
handle_tool_calls(message.tool_calls, messages, mode, working_dir)
|
||||||
elif not message.content or not message.content.strip():
|
elif not message.content or not message.content.strip():
|
||||||
# The model ended its turn with an empty message — most commonly
|
# The model ended its turn with an empty message, most commonly
|
||||||
# happens after a planning-only tool call (scratchpad / todo).
|
# happens after a planning-only tool call (scratchpad / todo).
|
||||||
# Nudge it to continue rather than silently stalling.
|
# Nudge it to continue rather than silently stalling.
|
||||||
messages.append({
|
messages.append({
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ def ask_question(question: str) -> str:
|
|||||||
try:
|
try:
|
||||||
answer = input(" Your answer: ").strip()
|
answer = input(" Your answer: ").strip()
|
||||||
except EOFError:
|
except EOFError:
|
||||||
return "(no answer — EOF)"
|
return "(no answer, EOF)"
|
||||||
return answer if answer else "(no answer provided)"
|
return answer if answer else "(no answer provided)"
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ def get_tool_schemas():
|
|||||||
"and cannot reasonably infer it from context. "
|
"and cannot reasonably infer it from context. "
|
||||||
"Ask one focused question at a time. "
|
"Ask one focused question at a time. "
|
||||||
"Do not use this for progress updates or to confirm actions you can already "
|
"Do not use this for progress updates or to confirm actions you can already "
|
||||||
"take — only ask when you are genuinely blocked."
|
"take, only ask when you are genuinely blocked."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def todo_update(id, content=None, status=None) -> str:
|
|||||||
if item["status"] == "in_progress" and retries > 0:
|
if item["status"] == "in_progress" and retries > 0:
|
||||||
if retries >= RETRY_LIMIT:
|
if retries >= RETRY_LIMIT:
|
||||||
return (
|
return (
|
||||||
f"Updated to do item {id} to in_progress — "
|
f"Updated to do item {id} to in_progress, "
|
||||||
f"but this is retry {retries} of {
|
f"but this is retry {retries} of {
|
||||||
RETRY_LIMIT} (retry limit reached). "
|
RETRY_LIMIT} (retry limit reached). "
|
||||||
f"Do not retry again. Escalate to the user instead."
|
f"Do not retry again. Escalate to the user instead."
|
||||||
|
|||||||
@@ -82,22 +82,22 @@ def agent_loop(client):
|
|||||||
"todo_append (status: pending).\n"
|
"todo_append (status: pending).\n"
|
||||||
"3. Before starting a step, mark it in_progress with todo_update. "
|
"3. Before starting a step, mark it in_progress with todo_update. "
|
||||||
"Keep only one item in_progress at a time.\n"
|
"Keep only one item in_progress at a time.\n"
|
||||||
"4. Mark items done immediately after completing them — do not batch completions.\n"
|
"4. Mark items done immediately after completing them, do not batch completions.\n"
|
||||||
"5. Call todo_list to review remaining work before moving to the next step.\n"
|
"5. Call todo_list to review remaining work before moving to the next step.\n"
|
||||||
"6. Mark tasks cancelled if they become unnecessary.\n\n"
|
"6. Mark tasks cancelled if they become unnecessary.\n\n"
|
||||||
"For simple, single-step tasks: act directly without creating todos.\n\n"
|
"For simple, single-step tasks: act directly without creating todos.\n\n"
|
||||||
"Planning tool calls (write_scratchpad, todo_append, todo_update, todo_list) "
|
"Planning tool calls (write_scratchpad, todo_append, todo_update, todo_list) "
|
||||||
"are internal bookkeeping, not responses to the user. After any planning tool "
|
"are internal bookkeeping, not responses to the user. After any planning tool "
|
||||||
"call, always continue working immediately — make your next tool call or, once "
|
"call, always continue working immediately, make your next tool call or, once "
|
||||||
"the task is fully complete, give a substantive final answer. "
|
"the task is fully complete, give a substantive final answer. "
|
||||||
"Never emit an empty or whitespace-only message.\n\n"
|
"Never emit an empty or whitespace-only message.\n\n"
|
||||||
"## Replanning\n\n"
|
"## Replanning\n\n"
|
||||||
"After every tool result, check whether the outcome matched your expectation. "
|
"After every tool result, check whether the outcome matched your expectation. "
|
||||||
"If a tool returns an error, unexpected output, or reveals information that "
|
"If a tool returns an error, unexpected output, or reveals information that "
|
||||||
"changes your understanding of the task, do not move to the next planned step — "
|
"changes your understanding of the task, do not move to the next planned step, "
|
||||||
"replan first.\n\n"
|
"replan first.\n\n"
|
||||||
"When a step fails:\n"
|
"When a step fails:\n"
|
||||||
"1. Diagnose in the scratchpad — is this a recoverable input error (wrong path, "
|
"1. Diagnose in the scratchpad: is this a recoverable input error (wrong path, "
|
||||||
"typo, wrong argument) or a deeper problem (wrong approach, wrong assumption)?\n"
|
"typo, wrong argument) or a deeper problem (wrong approach, wrong assumption)?\n"
|
||||||
"2. Mark the task failed: todo_update(id, status='failed').\n"
|
"2. Mark the task failed: todo_update(id, status='failed').\n"
|
||||||
"3. Choose a recovery action:\n"
|
"3. Choose a recovery action:\n"
|
||||||
@@ -107,8 +107,8 @@ def agent_loop(client):
|
|||||||
" - Reorder: new information makes a different task more urgent. Update the "
|
" - Reorder: new information makes a different task more urgent. Update the "
|
||||||
"pending items before continuing.\n"
|
"pending items before continuing.\n"
|
||||||
"4. If todo_update reports that the retry limit has been reached, stop retrying. "
|
"4. If todo_update reports that the retry limit has been reached, stop retrying. "
|
||||||
"Write a clear diagnosis in the scratchpad — what you tried, what failed each "
|
"Write a clear diagnosis in the scratchpad: what you tried, what failed each "
|
||||||
"time, and what you need — then give the user a concise escalation message "
|
"time, and what you need, then give the user a concise escalation message "
|
||||||
"and wait for their input.\n\n"
|
"and wait for their input.\n\n"
|
||||||
"When a tool succeeds but returns information that changes the picture, pause "
|
"When a tool succeeds but returns information that changes the picture, pause "
|
||||||
"before acting. Call todo_list, reassess all pending items in the scratchpad, "
|
"before acting. Call todo_list, reassess all pending items in the scratchpad, "
|
||||||
@@ -116,34 +116,34 @@ def agent_loop(client):
|
|||||||
"## How to use the scratchpad\n\n"
|
"## How to use the scratchpad\n\n"
|
||||||
"Before each tool call during a complex task, update the scratchpad with your "
|
"Before each tool call during a complex task, update the scratchpad with your "
|
||||||
"current thinking. Structure each entry around these five steps:\n\n"
|
"current thinking. Structure each entry around these five steps:\n\n"
|
||||||
"1. Restate the goal — write what you understand the task to be, in your own words. "
|
"1. Restate the goal: write what you understand the task to be, in your own words. "
|
||||||
"This catches misreads before they compound into wasted work.\n"
|
"This catches misreads before they compound into wasted work.\n"
|
||||||
"2. Survey what you know — note which files you have seen, what the code structure "
|
"2. Survey what you know: note which files you have seen, what the code structure "
|
||||||
"looks like, and what constraints or requirements apply.\n"
|
"looks like, and what constraints or requirements apply.\n"
|
||||||
"3. Evaluate options — reason through at least two approaches and explain why you "
|
"3. Evaluate options: reason through at least two approaches and explain why you "
|
||||||
"are choosing one over the other (e.g. 'I could rewrite the middleware, or wrap it. "
|
"are choosing one over the other (e.g. 'I could rewrite the middleware, or wrap it. "
|
||||||
"Wrapping is safer because it leaves the existing call sites untouched.').\n"
|
"Wrapping is safer because it leaves the existing call sites untouched.').\n"
|
||||||
"4. Anticipate failure modes — write down what could go wrong with the chosen "
|
"4. Anticipate failure modes: write down what could go wrong with the chosen "
|
||||||
"approach and how you would diagnose it (e.g. 'If the tests fail after this, the "
|
"approach and how you would diagnose it (e.g. 'If the tests fail after this, the "
|
||||||
"most likely cause is that the session cookie name changed.').\n"
|
"most likely cause is that the session cookie name changed.').\n"
|
||||||
"5. Decide the next single action — commit to exactly one tool call. "
|
"5. Decide the next single action: commit to exactly one tool call. "
|
||||||
"Do not plan several calls at once; decide the next step only.\n\n"
|
"Do not plan several calls at once; decide the next step only.\n\n"
|
||||||
"Re-read the scratchpad whenever you resume after a tool result to keep your "
|
"Re-read the scratchpad whenever you resume after a tool result to keep your "
|
||||||
"reasoning grounded in what you have already learned.\n\n"
|
"reasoning grounded in what you have already learned.\n\n"
|
||||||
"## Done detection\n\n"
|
"## Done detection\n\n"
|
||||||
"Do not give a final answer based on the task list being empty alone. "
|
"Do not give a final answer based on the task list being empty alone. "
|
||||||
"Before declaring the task complete, verify all three of the following:\n\n"
|
"Before declaring the task complete, verify all three of the following:\n\n"
|
||||||
"1. Structural completion — call todo_list and confirm there are no pending, "
|
"1. Structural completion: call todo_list and confirm there are no pending, "
|
||||||
"in_progress, or failed items.\n"
|
"in_progress, or failed items.\n"
|
||||||
"2. Verification — check the output against the original goal. For code tasks: "
|
"2. Verification: check the output against the original goal. For code tasks: "
|
||||||
"run the tests or build with run_bash and confirm they pass. For research tasks: "
|
"run the tests or build with run_bash and confirm they pass. For research tasks: "
|
||||||
"re-read the scratchpad and confirm the assembled answer addresses what was "
|
"re-read the scratchpad and confirm the assembled answer addresses what was "
|
||||||
"actually asked.\n"
|
"actually asked.\n"
|
||||||
"3. Uncertainty check — read the scratchpad and ask: are there unresolved "
|
"3. Uncertainty check: read the scratchpad and ask: are there unresolved "
|
||||||
"questions, assumptions that were never validated, or tasks that were cancelled "
|
"questions, assumptions that were never validated, or tasks that were cancelled "
|
||||||
"rather than properly completed?\n\n"
|
"rather than properly completed?\n\n"
|
||||||
"If all three are satisfied, give your final answer. If any are not, re-enter "
|
"If all three are satisfied, give your final answer. If any are not, re-enter "
|
||||||
"the planning loop — add the outstanding items to the todo list and continue."
|
"the planning loop, add the outstanding items to the todo list and continue."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -170,10 +170,10 @@ def agent_loop(client):
|
|||||||
messages.append(message)
|
messages.append(message)
|
||||||
|
|
||||||
if message.tool_calls:
|
if message.tool_calls:
|
||||||
# The LLM wants to use one or more tools — run them, then loop
|
# The LLM wants to use one or more tools, run them, then loop
|
||||||
handle_tool_calls(message.tool_calls, messages)
|
handle_tool_calls(message.tool_calls, messages)
|
||||||
elif not message.content or not message.content.strip():
|
elif not message.content or not message.content.strip():
|
||||||
# The model ended its turn with an empty message — most commonly
|
# The model ended its turn with an empty message, most commonly
|
||||||
# happens after a planning-only tool call (scratchpad / todo).
|
# happens after a planning-only tool call (scratchpad / todo).
|
||||||
# Nudge it to continue rather than silently stalling.
|
# Nudge it to continue rather than silently stalling.
|
||||||
messages.append({
|
messages.append({
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def todo_update(id, content=None, status=None) -> str:
|
|||||||
if item["status"] == "in_progress" and retries > 0:
|
if item["status"] == "in_progress" and retries > 0:
|
||||||
if retries >= RETRY_LIMIT:
|
if retries >= RETRY_LIMIT:
|
||||||
return (
|
return (
|
||||||
f"Updated to do item {id} to in_progress — "
|
f"Updated to do item {id} to in_progress, "
|
||||||
f"but this is retry {retries} of {
|
f"but this is retry {retries} of {
|
||||||
RETRY_LIMIT} (retry limit reached). "
|
RETRY_LIMIT} (retry limit reached). "
|
||||||
f"Do not retry again. Escalate to the user instead."
|
f"Do not retry again. Escalate to the user instead."
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def _ask_permission(tool_name: str, args: dict) -> bool:
|
|||||||
try:
|
try:
|
||||||
answer = input(" Allow this action? [y/n]: ").strip().lower()
|
answer = input(" Allow this action? [y/n]: ").strip().lower()
|
||||||
except EOFError:
|
except EOFError:
|
||||||
print(" (EOF — denying permission)")
|
print(" (EOF, denying permission)")
|
||||||
return False
|
return False
|
||||||
if answer in ("y", "yes"):
|
if answer in ("y", "yes"):
|
||||||
return True
|
return True
|
||||||
@@ -118,9 +118,9 @@ def check_permission(
|
|||||||
|
|
||||||
Three layers, evaluated in order:
|
Three layers, evaluated in order:
|
||||||
|
|
||||||
1. Tool policy (§2.2 / §2.3) — a hard gate that no mode can
|
1. Tool policy: a hard gate that no mode can
|
||||||
override: path scoping, shell denylist, SSRF guard.
|
override: path scoping, shell denylist, SSRF guard.
|
||||||
2. Always-confirm patterns (§2.2) — destructive calls require
|
2. Always-confirm patterns: destructive calls require
|
||||||
explicit confirmation even in acceptEdits, and selected
|
explicit confirmation even in acceptEdits, and selected
|
||||||
irreversible patterns refuse even in dangerouslySkipPermissions.
|
irreversible patterns refuse even in dangerouslySkipPermissions.
|
||||||
3. Mode-based decision (default / acceptEdits / skip).
|
3. Mode-based decision (default / acceptEdits / skip).
|
||||||
@@ -138,7 +138,7 @@ def check_permission(
|
|||||||
All other tools require explicit user approval.
|
All other tools require explicit user approval.
|
||||||
|
|
||||||
dangerouslySkipPermissions
|
dangerouslySkipPermissions
|
||||||
All tools run without any prompt — EXCEPT calls blocked by layer
|
All tools run without any prompt, EXCEPT calls blocked by layer
|
||||||
1 (hard policy) or layer 2 (always-confirm / irreversible).
|
1 (hard policy) or layer 2 (always-confirm / irreversible).
|
||||||
"""
|
"""
|
||||||
# Layer 1: hard policy gate (path scope, shell, SSRF).
|
# Layer 1: hard policy gate (path scope, shell, SSRF).
|
||||||
@@ -171,11 +171,11 @@ def check_permission(
|
|||||||
if mode == PermissionMode.ACCEPT_EDITS and tool_name in WRITE_TOOLS:
|
if mode == PermissionMode.ACCEPT_EDITS and tool_name in WRITE_TOOLS:
|
||||||
path = _resolve_tool_path(tool_name, args)
|
path = _resolve_tool_path(tool_name, args)
|
||||||
if path and _is_within_working_dir(path, working_dir):
|
if path and _is_within_working_dir(path, working_dir):
|
||||||
# an edit that empties an existing file is a delete —
|
# an edit that empties an existing file is a delete,
|
||||||
# require confirmation rather than auto-approving.
|
# require confirmation rather than auto-approving.
|
||||||
if _is_delete_via_write(tool_name, args, path):
|
if _is_delete_via_write(tool_name, args, path):
|
||||||
return _ask_permission(f"{tool_name} [DELETE-via-empty]", args), None
|
return _ask_permission(f"{tool_name} [DELETE-via-empty]", args), None
|
||||||
return True, None # auto-approved — within the working directory
|
return True, None # auto-approved, within the working directory
|
||||||
# Path is outside the working directory → fall through to ask
|
# Path is outside the working directory → fall through to ask
|
||||||
|
|
||||||
# Default mode, or acceptEdits for non-write / out-of-tree tools
|
# Default mode, or acceptEdits for non-write / out-of-tree tools
|
||||||
@@ -212,8 +212,8 @@ def build_tool_registry(
|
|||||||
Action tools (filesystem, shell, web) are dispatched into the
|
Action tools (filesystem, shell, web) are dispatched into the
|
||||||
long-lived Docker sandbox container.
|
long-lived Docker sandbox container.
|
||||||
|
|
||||||
If *allowed_tools* is given, only tools in that set are exposed —
|
If *allowed_tools* is given, only tools in that set are exposed,
|
||||||
this is the principle of least privilege (checklist §2.1). ``None``
|
this is the principle of least privilege. ``None``
|
||||||
means "expose everything".
|
means "expose everything".
|
||||||
"""
|
"""
|
||||||
registry = {}
|
registry = {}
|
||||||
@@ -271,13 +271,13 @@ def handle_tool_calls(
|
|||||||
Every call passes through these layers, in order, and each decision
|
Every call passes through these layers, in order, and each decision
|
||||||
is recorded in the audit log:
|
is recorded in the audit log:
|
||||||
|
|
||||||
0. Abort check (§6.3) + iteration cap (§4.1).
|
0. Abort check + iteration cap.
|
||||||
1. Schema validation (§3.1/§3.3).
|
1. Schema validation.
|
||||||
2. Permission gate (§2.2/§2.3) — may prompt the user.
|
2. Permission gate, may prompt the user.
|
||||||
3. Execution: action tools run inside the Docker sandbox container;
|
3. Execution: action tools run inside the Docker sandbox container;
|
||||||
planning tools run in-process on the host. A container failure
|
planning tools run in-process on the host. A container failure
|
||||||
or timeout is reported back to the LLM as an error.
|
or timeout is reported back to the LLM as an error.
|
||||||
4. File rollback snapshot (§6.3) before write/edit.
|
4. File rollback snapshot before write/edit.
|
||||||
"""
|
"""
|
||||||
caps = iteration_caps or IterationCaps()
|
caps = iteration_caps or IterationCaps()
|
||||||
abort = abort_controller or AbortController()
|
abort = abort_controller or AbortController()
|
||||||
@@ -297,7 +297,7 @@ def handle_tool_calls(
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# §4.1 session-level tool-call cap.
|
# session-level tool-call cap.
|
||||||
cap_reason = caps.bump_tool_call()
|
cap_reason = caps.bump_tool_call()
|
||||||
if cap_reason is not None:
|
if cap_reason is not None:
|
||||||
audit.log("iteration_cap_hit", scope="session",
|
audit.log("iteration_cap_hit", scope="session",
|
||||||
@@ -349,7 +349,7 @@ def handle_tool_calls(
|
|||||||
# Schema-validate tool inputs: parse and validate before
|
# Schema-validate tool inputs: parse and validate before
|
||||||
# any policy/permission check. A malformed call is reported
|
# any policy/permission check. A malformed call is reported
|
||||||
# back to the LLM with the specific errors so it can correct
|
# back to the LLM with the specific errors so it can correct
|
||||||
# and retry — it never reaches the sandbox or the permission
|
# and retry, it never reaches the sandbox or the permission
|
||||||
# gate.
|
# gate.
|
||||||
try:
|
try:
|
||||||
args = json.loads(tool_call.function.arguments)
|
args = json.loads(tool_call.function.arguments)
|
||||||
@@ -460,7 +460,7 @@ def handle_tool_calls(
|
|||||||
name, args, mode, working_dir,
|
name, args, mode, working_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
# §6.1 log the permission decision as a standalone event.
|
# log the permission decision as a standalone event.
|
||||||
audit.log_permission_decision(
|
audit.log_permission_decision(
|
||||||
tool=name,
|
tool=name,
|
||||||
args=args,
|
args=args,
|
||||||
@@ -479,7 +479,7 @@ def handle_tool_calls(
|
|||||||
# File rollback: snapshot the original file before
|
# File rollback: snapshot the original file before
|
||||||
# any write/edit so the session can offer to revert on
|
# any write/edit so the session can offer to revert on
|
||||||
# abort. Only files inside the working dir are
|
# abort. Only files inside the working dir are
|
||||||
# snapshotted (writes outside are already blocked by §2.3).
|
# snapshotted (writes outside are already blocked by).
|
||||||
if name in ("write_file", "edit_file"):
|
if name in ("write_file", "edit_file"):
|
||||||
target_path = args.get("path", "")
|
target_path = args.get("path", "")
|
||||||
if target_path:
|
if target_path:
|
||||||
@@ -495,7 +495,7 @@ def handle_tool_calls(
|
|||||||
"not execute this tool. Do not retry without changing "
|
"not execute this tool. Do not retry without changing "
|
||||||
"the approach or asking the user."
|
"the approach or asking the user."
|
||||||
)
|
)
|
||||||
# §4.3 distinguish timeouts from other container errors
|
# distinguish timeouts from other container errors
|
||||||
# for the audit log.
|
# for the audit log.
|
||||||
if "timed out" in str(e).lower():
|
if "timed out" in str(e).lower():
|
||||||
audit.log("tool_timeout", tool=name,
|
audit.log("tool_timeout", tool=name,
|
||||||
@@ -563,7 +563,7 @@ def agent_loop(
|
|||||||
container_name: str | None = None,
|
container_name: str | None = None,
|
||||||
approve_plan: bool = False,
|
approve_plan: bool = False,
|
||||||
):
|
):
|
||||||
# resource controls: the harness — never the model — owns these.
|
# resource controls: the harness, never the model, owns these.
|
||||||
iteration_caps = iteration_caps or IterationCaps()
|
iteration_caps = iteration_caps or IterationCaps()
|
||||||
context_budget = context_budget or ContextBudget()
|
context_budget = context_budget or ContextBudget()
|
||||||
cost_tracker = cost_tracker or CostTracker()
|
cost_tracker = cost_tracker or CostTracker()
|
||||||
@@ -603,18 +603,18 @@ def agent_loop(
|
|||||||
"- Clarification (ask_question): ask the user a single focused question when you "
|
"- Clarification (ask_question): ask the user a single focused question when you "
|
||||||
"are genuinely blocked and cannot reasonably infer the missing information from "
|
"are genuinely blocked and cannot reasonably infer the missing information from "
|
||||||
"context. Do not use it for progress updates or to confirm actions you can already "
|
"context. Do not use it for progress updates or to confirm actions you can already "
|
||||||
"take — only ask when it is strictly necessary to proceed.\n\n"
|
"take, only ask when it is strictly necessary to proceed.\n\n"
|
||||||
|
|
||||||
"## Execution environment (Docker sandbox)\n\n"
|
"## Execution environment (Docker sandbox)\n\n"
|
||||||
"Action tools — read_file, write_file, edit_file, glob_files, grep, run_bash, "
|
"Action tools: read_file, write_file, edit_file, glob_files, grep, run_bash, "
|
||||||
"webfetch — execute inside a Docker container. The user's project directory is "
|
"webfetch, execute inside a Docker container. The user's project directory is "
|
||||||
"bind-mounted into that container at the same path as on the host, and the "
|
"bind-mounted into that container at the same path as on the host, and the "
|
||||||
"container's working directory is the project root. You can only see and modify "
|
"container's working directory is the project root. You can only see and modify "
|
||||||
"files inside the project mount; the rest of the container's filesystem is a "
|
"files inside the project mount; the rest of the container's filesystem is a "
|
||||||
"minimal, read-only-by-convention Linux image. If the session was started with "
|
"minimal, read-only-by-convention Linux image. If the session was started with "
|
||||||
"network disabled, run_bash and webfetch that need network will fail — treat that "
|
"network disabled, run_bash and webfetch that need network will fail, treat that "
|
||||||
"as expected, not as a bug to work around. If a tool returns 'Container error: "
|
"as expected, not as a bug to work around. If a tool returns 'Container error: "
|
||||||
"...', do not retry the same call — adjust the approach or ask the user to run it "
|
"...', do not retry the same call, adjust the approach or ask the user to run it "
|
||||||
"manually. Planning tools run on the host, not in the container, so their state "
|
"manually. Planning tools run on the host, not in the container, so their state "
|
||||||
"persists across calls.\n\n"
|
"persists across calls.\n\n"
|
||||||
|
|
||||||
@@ -632,7 +632,7 @@ def agent_loop(
|
|||||||
"todo_append (status: pending).\n"
|
"todo_append (status: pending).\n"
|
||||||
"3. Before starting a step, mark it in_progress with todo_update. "
|
"3. Before starting a step, mark it in_progress with todo_update. "
|
||||||
"Keep only one item in_progress at a time.\n"
|
"Keep only one item in_progress at a time.\n"
|
||||||
"4. Mark items done immediately after completing them — do not batch completions.\n"
|
"4. Mark items done immediately after completing them, do not batch completions.\n"
|
||||||
"5. Call todo_list to review remaining work before moving to the next step.\n"
|
"5. Call todo_list to review remaining work before moving to the next step.\n"
|
||||||
"6. Mark tasks cancelled if they become unnecessary.\n\n"
|
"6. Mark tasks cancelled if they become unnecessary.\n\n"
|
||||||
|
|
||||||
@@ -640,16 +640,16 @@ def agent_loop(
|
|||||||
|
|
||||||
"Planning tool calls (write_scratchpad, todo_append, todo_update, todo_list) "
|
"Planning tool calls (write_scratchpad, todo_append, todo_update, todo_list) "
|
||||||
"are internal bookkeeping, not responses to the user. After any planning tool "
|
"are internal bookkeeping, not responses to the user. After any planning tool "
|
||||||
"call, always continue working immediately — make your next tool call or, once "
|
"call, always continue working immediately, make your next tool call or, once "
|
||||||
"the task is fully complete, give a substantive final answer. "
|
"the task is fully complete, give a substantive final answer. "
|
||||||
"Never emit an empty or whitespace-only message.\n\n"
|
"Never emit an empty or whitespace-only message.\n\n"
|
||||||
"## Replanning\n\n"
|
"## Replanning\n\n"
|
||||||
"After every tool result, check whether the outcome matched your expectation. "
|
"After every tool result, check whether the outcome matched your expectation. "
|
||||||
"If a tool returns an error, unexpected output, or reveals information that "
|
"If a tool returns an error, unexpected output, or reveals information that "
|
||||||
"changes your understanding of the task, do not move to the next planned step — "
|
"changes your understanding of the task, do not move to the next planned step, "
|
||||||
"replan first.\n\n"
|
"replan first.\n\n"
|
||||||
"When a step fails:\n"
|
"When a step fails:\n"
|
||||||
"1. Diagnose in the scratchpad — is this a recoverable input error (wrong path, "
|
"1. Diagnose in the scratchpad: is this a recoverable input error (wrong path, "
|
||||||
"typo, wrong argument) or a deeper problem (wrong approach, wrong assumption)?\n"
|
"typo, wrong argument) or a deeper problem (wrong approach, wrong assumption)?\n"
|
||||||
"2. Mark the task failed: todo_update(id, status='failed').\n"
|
"2. Mark the task failed: todo_update(id, status='failed').\n"
|
||||||
"3. Choose a recovery action:\n"
|
"3. Choose a recovery action:\n"
|
||||||
@@ -659,8 +659,8 @@ def agent_loop(
|
|||||||
" - Reorder: new information makes a different task more urgent. Update the "
|
" - Reorder: new information makes a different task more urgent. Update the "
|
||||||
"pending items before continuing.\n"
|
"pending items before continuing.\n"
|
||||||
"4. If todo_update reports that the retry limit has been reached, stop retrying. "
|
"4. If todo_update reports that the retry limit has been reached, stop retrying. "
|
||||||
"Write a clear diagnosis in the scratchpad — what you tried, what failed each "
|
"Write a clear diagnosis in the scratchpad: what you tried, what failed each "
|
||||||
"time, and what you need — then give the user a concise escalation message "
|
"time, and what you need, then give the user a concise escalation message "
|
||||||
"and wait for their input.\n\n"
|
"and wait for their input.\n\n"
|
||||||
"When a tool succeeds but returns information that changes the picture, pause "
|
"When a tool succeeds but returns information that changes the picture, pause "
|
||||||
"before acting. Call todo_list, reassess all pending items in the scratchpad, "
|
"before acting. Call todo_list, reassess all pending items in the scratchpad, "
|
||||||
@@ -668,34 +668,34 @@ def agent_loop(
|
|||||||
"## How to use the scratchpad\n\n"
|
"## How to use the scratchpad\n\n"
|
||||||
"Before each tool call during a complex task, update the scratchpad with your "
|
"Before each tool call during a complex task, update the scratchpad with your "
|
||||||
"current thinking. Structure each entry around these five steps:\n\n"
|
"current thinking. Structure each entry around these five steps:\n\n"
|
||||||
"1. Restate the goal — write what you understand the task to be, in your own words. "
|
"1. Restate the goal: write what you understand the task to be, in your own words. "
|
||||||
"This catches misreads before they compound into wasted work.\n"
|
"This catches misreads before they compound into wasted work.\n"
|
||||||
"2. Survey what you know — note which files you have seen, what the code structure "
|
"2. Survey what you know: note which files you have seen, what the code structure "
|
||||||
"looks like, and what constraints or requirements apply.\n"
|
"looks like, and what constraints or requirements apply.\n"
|
||||||
"3. Evaluate options — reason through at least two approaches and explain why you "
|
"3. Evaluate options: reason through at least two approaches and explain why you "
|
||||||
"are choosing one over the other (e.g. 'I could rewrite the middleware, or wrap it. "
|
"are choosing one over the other (e.g. 'I could rewrite the middleware, or wrap it. "
|
||||||
"Wrapping is safer because it leaves the existing call sites untouched.').\n"
|
"Wrapping is safer because it leaves the existing call sites untouched.').\n"
|
||||||
"4. Anticipate failure modes — write down what could go wrong with the chosen "
|
"4. Anticipate failure modes: write down what could go wrong with the chosen "
|
||||||
"approach and how you would diagnose it (e.g. 'If the tests fail after this, the "
|
"approach and how you would diagnose it (e.g. 'If the tests fail after this, the "
|
||||||
"most likely cause is that the session cookie name changed.').\n"
|
"most likely cause is that the session cookie name changed.').\n"
|
||||||
"5. Decide the next single action — commit to exactly one tool call. "
|
"5. Decide the next single action: commit to exactly one tool call. "
|
||||||
"Do not plan several calls at once; decide the next step only.\n\n"
|
"Do not plan several calls at once; decide the next step only.\n\n"
|
||||||
"Re-read the scratchpad whenever you resume after a tool result to keep your "
|
"Re-read the scratchpad whenever you resume after a tool result to keep your "
|
||||||
"reasoning grounded in what you have already learned.\n\n"
|
"reasoning grounded in what you have already learned.\n\n"
|
||||||
"## Done detection\n\n"
|
"## Done detection\n\n"
|
||||||
"Do not give a final answer based on the task list being empty alone. "
|
"Do not give a final answer based on the task list being empty alone. "
|
||||||
"Before declaring the task complete, verify all three of the following:\n\n"
|
"Before declaring the task complete, verify all three of the following:\n\n"
|
||||||
"1. Structural completion — call todo_list and confirm there are no pending, "
|
"1. Structural completion: call todo_list and confirm there are no pending, "
|
||||||
"in_progress, or failed items.\n"
|
"in_progress, or failed items.\n"
|
||||||
"2. Verification — check the output against the original goal. For code tasks: "
|
"2. Verification: check the output against the original goal. For code tasks: "
|
||||||
"run the tests or build with run_bash and confirm they pass. For research tasks: "
|
"run the tests or build with run_bash and confirm they pass. For research tasks: "
|
||||||
"re-read the scratchpad and confirm the assembled answer addresses what was "
|
"re-read the scratchpad and confirm the assembled answer addresses what was "
|
||||||
"actually asked.\n"
|
"actually asked.\n"
|
||||||
"3. Uncertainty check — read the scratchpad and ask: are there unresolved "
|
"3. Uncertainty check: read the scratchpad and ask: are there unresolved "
|
||||||
"questions, assumptions that were never validated, or tasks that were cancelled "
|
"questions, assumptions that were never validated, or tasks that were cancelled "
|
||||||
"rather than properly completed?\n\n"
|
"rather than properly completed?\n\n"
|
||||||
"If all three are satisfied, give your final answer. If any are not, re-enter "
|
"If all three are satisfied, give your final answer. If any are not, re-enter "
|
||||||
"the planning loop — add the outstanding items to the todo list and continue."
|
"the planning loop, add the outstanding items to the todo list and continue."
|
||||||
)
|
)
|
||||||
_prompt_warnings = secret_management.audit_system_prompt(
|
_prompt_warnings = secret_management.audit_system_prompt(
|
||||||
_system_prompt_body)
|
_system_prompt_body)
|
||||||
@@ -795,7 +795,7 @@ def agent_loop(
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
tools=active_schemas,
|
tools=active_schemas,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
timeout=llm_timeout, # §4.3 LLM call timeout
|
timeout=llm_timeout,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# surface an LLM-call failure (including timeout)
|
# surface an LLM-call failure (including timeout)
|
||||||
@@ -807,7 +807,7 @@ def agent_loop(
|
|||||||
break
|
break
|
||||||
|
|
||||||
# record usage for cost tracking. Local backends
|
# record usage for cost tracking. Local backends
|
||||||
# (Ollama) may not populate usage; that's fine — the
|
# (Ollama) may not populate usage; that's fine, the
|
||||||
# tracker simply records zero tokens for the call.
|
# tracker simply records zero tokens for the call.
|
||||||
cost_tracker.record_usage(getattr(response, "usage", None))
|
cost_tracker.record_usage(getattr(response, "usage", None))
|
||||||
|
|
||||||
@@ -845,7 +845,7 @@ def agent_loop(
|
|||||||
audit.log_assistant_message(message.content, message.tool_calls)
|
audit.log_assistant_message(message.content, message.tool_calls)
|
||||||
|
|
||||||
if message.tool_calls:
|
if message.tool_calls:
|
||||||
# The LLM wants to use one or more tools — run them, then loop
|
# The LLM wants to use one or more tools, run them, then loop
|
||||||
handle_tool_calls(
|
handle_tool_calls(
|
||||||
message.tool_calls,
|
message.tool_calls,
|
||||||
messages,
|
messages,
|
||||||
@@ -862,7 +862,7 @@ def agent_loop(
|
|||||||
plan_state=plan_state,
|
plan_state=plan_state,
|
||||||
)
|
)
|
||||||
elif not message.content or not message.content.strip():
|
elif not message.content or not message.content.strip():
|
||||||
# The model ended its turn with an empty message — most commonly
|
# The model ended its turn with an empty message, most commonly
|
||||||
# happens after a planning-only tool call (scratchpad / todo).
|
# happens after a planning-only tool call (scratchpad / todo).
|
||||||
# Nudge it to continue rather than silently stalling.
|
# Nudge it to continue rather than silently stalling.
|
||||||
messages.append({
|
messages.append({
|
||||||
@@ -876,7 +876,7 @@ def agent_loop(
|
|||||||
# needed here. If a web UI is added in the future, the
|
# needed here. If a web UI is added in the future, the
|
||||||
# assistant content MUST be passed through html.escape
|
# assistant content MUST be passed through html.escape
|
||||||
# (or a template engine's auto-escaping) before being
|
# (or a template engine's auto-escaping) before being
|
||||||
# inserted into the DOM — never trust model output.
|
# inserted into the DOM, never trust model output.
|
||||||
print(f"Assistant: {message.content}")
|
print(f"Assistant: {message.content}")
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -930,7 +930,7 @@ if __name__ == "__main__":
|
|||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"Comma-separated allowlist of tool names to expose to the agent "
|
"Comma-separated allowlist of tool names to expose to the agent "
|
||||||
"(principle of least privilege, checklist §2.1). "
|
"(principle of least privilege). "
|
||||||
"Example: --tools read_file,glob_files,grep,run_bash. "
|
"Example: --tools read_file,glob_files,grep,run_bash. "
|
||||||
"Default: all tools are exposed."
|
"Default: all tools are exposed."
|
||||||
),
|
),
|
||||||
@@ -940,7 +940,7 @@ if __name__ == "__main__":
|
|||||||
type=float,
|
type=float,
|
||||||
default=120.0,
|
default=120.0,
|
||||||
help=(
|
help=(
|
||||||
"§4.3 Per-tool-call timeout in seconds. A tool that does not "
|
"Per-tool-call timeout in seconds. A tool that does not "
|
||||||
"finish in this time is killed and reported to the LLM as a "
|
"finish in this time is killed and reported to the LLM as a "
|
||||||
"timeout. Default: 120."
|
"timeout. Default: 120."
|
||||||
),
|
),
|
||||||
@@ -950,7 +950,7 @@ if __name__ == "__main__":
|
|||||||
type=float,
|
type=float,
|
||||||
default=120.0,
|
default=120.0,
|
||||||
help=(
|
help=(
|
||||||
"§4.3 Timeout for each LLM completion call in seconds. "
|
"Timeout for each LLM completion call in seconds. "
|
||||||
"Default: 120."
|
"Default: 120."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -959,7 +959,7 @@ if __name__ == "__main__":
|
|||||||
type=int,
|
type=int,
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"§4.1 Max LLM turns per user message before the loop stops "
|
"Max LLM turns per user message before the loop stops "
|
||||||
f"and asks for a summary. Default: {
|
f"and asks for a summary. Default: {
|
||||||
resource_limits.DEFAULT_MAX_TURNS_PER_USER_MSG}."
|
resource_limits.DEFAULT_MAX_TURNS_PER_USER_MSG}."
|
||||||
),
|
),
|
||||||
@@ -969,7 +969,7 @@ if __name__ == "__main__":
|
|||||||
type=int,
|
type=int,
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"§4.1 Max tool calls for the whole session. Default: "
|
"Max tool calls for the whole session. Default: "
|
||||||
f"{resource_limits.DEFAULT_MAX_TOOL_CALLS_PER_SESSION}."
|
f"{resource_limits.DEFAULT_MAX_TOOL_CALLS_PER_SESSION}."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -978,7 +978,7 @@ if __name__ == "__main__":
|
|||||||
type=int,
|
type=int,
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"§4.2 Token budget for the message history. When exceeded, "
|
"Token budget for the message history. When exceeded, "
|
||||||
"older messages are trimmed and replaced with a summary. "
|
"older messages are trimmed and replaced with a summary. "
|
||||||
f"Default: {resource_limits.DEFAULT_MAX_CONTEXT_TOKENS}."
|
f"Default: {resource_limits.DEFAULT_MAX_CONTEXT_TOKENS}."
|
||||||
),
|
),
|
||||||
@@ -988,7 +988,7 @@ if __name__ == "__main__":
|
|||||||
type=float,
|
type=float,
|
||||||
default=None,
|
default=None,
|
||||||
help=(
|
help=(
|
||||||
"§4.4 Cumulative API spend cap in USD. When exceeded, the "
|
"Cumulative API spend cap in USD. When exceeded, the "
|
||||||
"session aborts gracefully. Default: "
|
"session aborts gracefully. Default: "
|
||||||
f"{resource_limits.DEFAULT_MAX_COST_USD:.2f} (no effect for "
|
f"{resource_limits.DEFAULT_MAX_COST_USD:.2f} (no effect for "
|
||||||
"local backends that don't report usage)."
|
"local backends that don't report usage)."
|
||||||
@@ -999,7 +999,7 @@ if __name__ == "__main__":
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help=(
|
help=(
|
||||||
"§6.2 Require human approval of the agent's plan before any "
|
"Require human approval of the agent's plan before any "
|
||||||
"action tool runs. The agent builds its plan in the "
|
"action tool runs. The agent builds its plan in the "
|
||||||
"scratchpad + todo list; the first time it tries to run an "
|
"scratchpad + todo list; the first time it tries to run an "
|
||||||
"action tool, the user is shown the plan and asked to approve."
|
"action tool, the user is shown the plan and asked to approve."
|
||||||
@@ -1038,7 +1038,7 @@ if __name__ == "__main__":
|
|||||||
print(" These will NOT be passed to the sandbox container "
|
print(" These will NOT be passed to the sandbox container "
|
||||||
"(only an allowlist is inherited).")
|
"(only an allowlist is inherited).")
|
||||||
|
|
||||||
# 5.2 Verify no credential mount paths would leak into the container.
|
# Verify no credential mount paths would leak into the container.
|
||||||
cred_mounts = secret_management.check_credential_mounts()
|
cred_mounts = secret_management.check_credential_mounts()
|
||||||
if cred_mounts:
|
if cred_mounts:
|
||||||
print(
|
print(
|
||||||
@@ -1057,8 +1057,8 @@ if __name__ == "__main__":
|
|||||||
project_root=sandbox_root,
|
project_root=sandbox_root,
|
||||||
tools_dir=tools_dir,
|
tools_dir=tools_dir,
|
||||||
network=cli_args.network,
|
network=cli_args.network,
|
||||||
exec_timeout=cli_args.tool_timeout, # §4.3
|
exec_timeout=cli_args.tool_timeout,
|
||||||
container_env=container_env, # §5.2 / §5.3
|
container_env=container_env,
|
||||||
)
|
)
|
||||||
except DockerSandboxError as e:
|
except DockerSandboxError as e:
|
||||||
print(f"Could not start sandbox: {e}")
|
print(f"Could not start sandbox: {e}")
|
||||||
@@ -1096,10 +1096,10 @@ if __name__ == "__main__":
|
|||||||
max_tool_calls_per_session=iteration_caps.max_tool_calls_per_session,
|
max_tool_calls_per_session=iteration_caps.max_tool_calls_per_session,
|
||||||
max_context_tokens=context_budget.max_tokens,
|
max_context_tokens=context_budget.max_tokens,
|
||||||
max_cost_usd=cost_tracker.max_cost_usd,
|
max_cost_usd=cost_tracker.max_cost_usd,
|
||||||
session_id=session_creds.session_id, # §5.3
|
session_id=session_creds.session_id,
|
||||||
secret_env_count=len(secret_env), # §5.1
|
secret_env_count=len(secret_env),
|
||||||
credential_mounts_found=[str(p) for p in cred_mounts], # §5.2
|
credential_mounts_found=[str(p) for p in cred_mounts],
|
||||||
container_env_allowlist=sorted(container_env.keys()), # §5.2
|
container_env_allowlist=sorted(container_env.keys()),
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Agent started in '{
|
print(f"Agent started in '{
|
||||||
@@ -1117,10 +1117,10 @@ if __name__ == "__main__":
|
|||||||
print(f"Audit log: {audit.path}")
|
print(f"Audit log: {audit.path}")
|
||||||
print("Type \\exit to quit. (Ctrl-C to abort a session)\n")
|
print("Type \\exit to quit. (Ctrl-C to abort a session)\n")
|
||||||
|
|
||||||
# session abort controller — installs SIGINT/SIGTERM handlers.
|
# session abort controller: installs SIGINT/SIGTERM handlers.
|
||||||
abort_controller = AbortController()
|
abort_controller = AbortController()
|
||||||
abort_controller.install_signal_handlers()
|
abort_controller.install_signal_handlers()
|
||||||
# file rollback — snapshots before write/edit for revert on abort.
|
# file rollback: snapshots before write/edit for revert on abort.
|
||||||
file_rollback = FileRollback()
|
file_rollback = FileRollback()
|
||||||
|
|
||||||
client = get_llm_client()
|
client = get_llm_client()
|
||||||
@@ -1144,7 +1144,7 @@ if __name__ == "__main__":
|
|||||||
if file_rollback.snapshot_count > 0:
|
if file_rollback.snapshot_count > 0:
|
||||||
file_rollback.offer_rollback()
|
file_rollback.offer_rollback()
|
||||||
file_rollback.cleanup()
|
file_rollback.cleanup()
|
||||||
session_creds.revoke() # §5.3 rotate on exit
|
session_creds.revoke()
|
||||||
sandbox.close()
|
sandbox.close()
|
||||||
audit.close()
|
audit.close()
|
||||||
print(f"\nSandbox container removed. Audit log written to {
|
print(f"\nSandbox container removed. Audit log written to {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ def mark_external_content(
|
|||||||
their content in an ``<external_document>`` tag so the model treats
|
their content in an ``<external_document>`` tag so the model treats
|
||||||
them as data rather than instructions.
|
them as data rather than instructions.
|
||||||
|
|
||||||
Error strings from the tools are returned unchanged — they are
|
Error strings from the tools are returned unchanged, they are
|
||||||
harness-generated, not external content.
|
harness-generated, not external content.
|
||||||
"""
|
"""
|
||||||
if tool_name == "webfetch":
|
if tool_name == "webfetch":
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Loop & resource controls (checklist §4).
|
"""Loop & resource controls.
|
||||||
|
|
||||||
This module centralises every limit that prevents the agent from
|
This module centralises every limit that prevents the agent from
|
||||||
running away with itself:
|
running away with itself:
|
||||||
|
|
||||||
§4.1 IterationCaps — hard caps on turns and tool calls.
|
IterationCaps: hard caps on turns and tool calls.
|
||||||
§4.2 ContextBudget — token budget with automatic context trimming.
|
ContextBudget: token budget with automatic context trimming.
|
||||||
§4.3 (timeouts live in sandbox.py / agent.py — wired here via a small
|
(timeouts live in sandbox.py / agent.py, wired here via a small
|
||||||
ToolTimeout helper that classifies timeout events for the audit
|
ToolTimeout helper that classifies timeout events for the audit
|
||||||
log).
|
log).
|
||||||
§4.4 CostTracker — cumulative API spend circuit breaker.
|
CostTracker: cumulative API spend circuit breaker.
|
||||||
|
|
||||||
All of these are *host-side* controls: the model never gets to vote on
|
All of these are *host-side* controls: the model never gets to vote on
|
||||||
them. They are evaluated between tool calls and before each LLM call.
|
them. They are evaluated between tool calls and before each LLM call.
|
||||||
@@ -22,7 +22,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4.1 Hard iteration caps
|
# Hard iteration caps
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Defaults are chosen to be generous enough for real coding tasks but
|
# Defaults are chosen to be generous enough for real coding tasks but
|
||||||
@@ -33,9 +33,9 @@ DEFAULT_MAX_TOOL_CALLS_PER_SESSION = 200
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class IterationCaps:
|
class IterationCaps:
|
||||||
"""Counters that enforce hard iteration caps (§4.1).
|
"""Counters that enforce hard iteration caps.
|
||||||
|
|
||||||
The harness — never the model — owns these limits. Two counters
|
The harness, never the model, owns these limits. Two counters
|
||||||
are tracked:
|
are tracked:
|
||||||
|
|
||||||
- ``turns``: incremented once per LLM response within a single
|
- ``turns``: incremented once per LLM response within a single
|
||||||
@@ -82,11 +82,11 @@ class IterationCaps:
|
|||||||
@property
|
@property
|
||||||
def breached(self) -> bool:
|
def breached(self) -> bool:
|
||||||
return self.turns > self.max_turns_per_user_msg or \
|
return self.turns > self.max_turns_per_user_msg or \
|
||||||
self.tool_calls > self.max_tool_calls_per_session
|
self.tool_calls > self.max_tool_calls_per_session
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4.2 Token budget enforcement
|
# Token budget enforcement
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Above this fraction of the model's context window we trim older
|
# Above this fraction of the model's context window we trim older
|
||||||
@@ -102,7 +102,7 @@ def estimate_tokens(text: str) -> int:
|
|||||||
"""Cheap token estimate: ~4 chars per token.
|
"""Cheap token estimate: ~4 chars per token.
|
||||||
|
|
||||||
Good enough for budget decisions; the real BPE count is only
|
Good enough for budget decisions; the real BPE count is only
|
||||||
needed for billing, which §4.4 handles via the API's own
|
needed for billing, which handles via the API's own
|
||||||
``usage`` field.
|
``usage`` field.
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
@@ -112,7 +112,8 @@ def estimate_tokens(text: str) -> int:
|
|||||||
|
|
||||||
def _message_token_count(msg: Any) -> int:
|
def _message_token_count(msg: Any) -> int:
|
||||||
"""Estimate tokens in a single chat message."""
|
"""Estimate tokens in a single chat message."""
|
||||||
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
|
content = msg.get("content") if isinstance(
|
||||||
|
msg, dict) else getattr(msg, "content", None)
|
||||||
if content is None:
|
if content is None:
|
||||||
return 0
|
return 0
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
@@ -124,7 +125,7 @@ def _message_token_count(msg: Any) -> int:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ContextBudget:
|
class ContextBudget:
|
||||||
"""Track cumulative tokens and trim the message history (§4.2).
|
"""Track cumulative tokens and trim the message history.
|
||||||
|
|
||||||
``check_and_trim`` is called before each LLM request. If the
|
``check_and_trim`` is called before each LLM request. If the
|
||||||
estimated token count exceeds ``max_tokens * trim_threshold`` it
|
estimated token count exceeds ``max_tokens * trim_threshold`` it
|
||||||
@@ -187,10 +188,12 @@ class ContextBudget:
|
|||||||
tool_calls: list[str] = []
|
tool_calls: list[str] = []
|
||||||
total_chars = 0
|
total_chars = 0
|
||||||
for m in dropped:
|
for m in dropped:
|
||||||
content = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
|
content = m.get("content") if isinstance(
|
||||||
|
m, dict) else getattr(m, "content", "")
|
||||||
if content:
|
if content:
|
||||||
total_chars += len(str(content))
|
total_chars += len(str(content))
|
||||||
tcs = m.get("tool_calls") if isinstance(m, dict) else getattr(m, "tool_calls", None)
|
tcs = m.get("tool_calls") if isinstance(
|
||||||
|
m, dict) else getattr(m, "tool_calls", None)
|
||||||
if tcs:
|
if tcs:
|
||||||
for tc in tcs:
|
for tc in tcs:
|
||||||
name = getattr(getattr(tc, "function", None), "name", None)
|
name = getattr(getattr(tc, "function", None), "name", None)
|
||||||
@@ -201,12 +204,14 @@ class ContextBudget:
|
|||||||
blob = str([
|
blob = str([
|
||||||
(m.get("role") if isinstance(m, dict) else getattr(m, "role", "")) for m in dropped
|
(m.get("role") if isinstance(m, dict) else getattr(m, "role", "")) for m in dropped
|
||||||
])
|
])
|
||||||
digest = hashlib.sha256(blob.encode("utf-8", "replace")).hexdigest()[:16]
|
digest = hashlib.sha256(blob.encode(
|
||||||
|
"utf-8", "replace")).hexdigest()[:16]
|
||||||
lines = [
|
lines = [
|
||||||
"[context-trim summary]",
|
"[context-trim summary]",
|
||||||
f"Earlier conversation dropped to stay within the token budget.",
|
f"Earlier conversation dropped to stay within the token budget.",
|
||||||
f"Dropped messages: {len(dropped)} ({total_chars} chars).",
|
f"Dropped messages: {len(dropped)} ({total_chars} chars).",
|
||||||
f"Tools called in dropped section: {', '.join(tool_calls) or 'none'}.",
|
f"Tools called in dropped section: {
|
||||||
|
', '.join(tool_calls) or 'none'}.",
|
||||||
f"Conversation hash (first 16 hex): {digest}.",
|
f"Conversation hash (first 16 hex): {digest}.",
|
||||||
"Re-read any files you need rather than relying on the dropped context.",
|
"Re-read any files you need rather than relying on the dropped context.",
|
||||||
]
|
]
|
||||||
@@ -214,7 +219,7 @@ class ContextBudget:
|
|||||||
|
|
||||||
|
|
||||||
def cap_tool_result(result: str, limit: int = DEFAULT_MAX_TOOL_RESULT_CHARS) -> str:
|
def cap_tool_result(result: str, limit: int = DEFAULT_MAX_TOOL_RESULT_CHARS) -> str:
|
||||||
"""Cap a tool result before it is inserted into messages (§4.2).
|
"""Cap a tool result before it is inserted into messages.
|
||||||
|
|
||||||
Long results (e.g. reading a 50k-line file) are truncated to
|
Long results (e.g. reading a 50k-line file) are truncated to
|
||||||
``limit`` chars with a notice appended so the model knows there is
|
``limit`` chars with a notice appended so the model knows there is
|
||||||
@@ -230,7 +235,7 @@ def cap_tool_result(result: str, limit: int = DEFAULT_MAX_TOOL_RESULT_CHARS) ->
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4.4 Cost circuit breakers
|
# Cost circuit breakers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Default per-session spend cap in USD. Generous for local Ollama
|
# Default per-session spend cap in USD. Generous for local Ollama
|
||||||
@@ -246,7 +251,7 @@ DEFAULT_PRICE_PER_1K_OUT = 0.000600
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CostTracker:
|
class CostTracker:
|
||||||
"""Accumulate API spend and abort when the cap is hit (§4.4).
|
"""Accumulate API spend and abort when the cap is hit.
|
||||||
|
|
||||||
After each ``chat.completions.create`` call, the caller invokes
|
After each ``chat.completions.create`` call, the caller invokes
|
||||||
``record_usage`` with the ``response.usage`` object (or None for
|
``record_usage`` with the ``response.usage`` object (or None for
|
||||||
@@ -292,7 +297,8 @@ class CostTracker:
|
|||||||
def summary(self) -> str:
|
def summary(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"calls={self.calls} tokens_in={self.total_tokens_in} "
|
f"calls={self.calls} tokens_in={self.total_tokens_in} "
|
||||||
f"tokens_out={self.total_tokens_out} cost=${self.total_cost_usd:.4f}"
|
f"tokens_out={self.total_tokens_out} cost=${
|
||||||
|
self.total_cost_usd:.4f}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
"""Secret & credential management (checklist §5).
|
"""Secret & credential management.
|
||||||
|
|
||||||
Three layers:
|
Three layers:
|
||||||
|
|
||||||
§5.1 Never put secrets in the system prompt.
|
Never put secrets in the system prompt.
|
||||||
- ``scan_environment_for_secrets`` runs at startup and warns
|
- ``scan_environment_for_secrets`` runs at startup and warns
|
||||||
about env vars whose names look like credentials.
|
about env vars whose names look like credentials.
|
||||||
- ``audit_system_prompt`` statically checks a prompt string for
|
- ``audit_system_prompt`` statically checks a prompt string for
|
||||||
f-string interpolation of env vars or known secret names.
|
f-string interpolation of env vars or known secret names.
|
||||||
|
|
||||||
§5.2 Credential injection at the harness level.
|
Credential injection at the harness level.
|
||||||
- ``build_container_env`` returns the minimal environment dict
|
- ``build_container_env`` returns the minimal environment dict
|
||||||
passed to the sandbox container — only an allowlist of vars
|
passed to the sandbox container: only an allowlist of vars
|
||||||
the agent genuinely needs, never raw host credentials.
|
the agent genuinely needs, never raw host credentials.
|
||||||
- ``CREDENTIAL_MOUNT_PATHS`` lists host files/dirs that must
|
- ``CREDENTIAL_MOUNT_PATHS`` lists host files/dirs that must
|
||||||
NOT be bind-mounted into the container (``~/.aws``,
|
NOT be bind-mounted into the container (``~/.aws``,
|
||||||
``~/.ssh``, ``~/.netrc``, …).
|
``~/.ssh``, ``~/.netrc``, …).
|
||||||
|
|
||||||
§5.3 Rotate credentials per session.
|
Rotate credentials per session.
|
||||||
- ``SessionCredentials`` generates a fresh per-session token
|
- ``SessionCredentials`` generates a fresh per-session token
|
||||||
via ``secrets.token_urlsafe``; the harness can pass it to
|
via ``secrets.token_urlsafe``; the harness can pass it to
|
||||||
the container and use it to authenticate any harness-side
|
the container and use it to authenticate any harness-side
|
||||||
@@ -34,12 +34,12 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5.1 Never put secrets in the system prompt
|
# Never put secrets in the system prompt
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Env-var name pattern that *looks* like a secret. Matching is
|
# Env-var name pattern that *looks* like a secret. Matching is
|
||||||
# case-insensitive. False positives (e.g. ``PRINTER_SETTINGS``) are
|
# case-insensitive. False positives (e.g. ``PRINTER_SETTINGS``) are
|
||||||
# fine — we only warn, we don't block.
|
# fine, we only warn, we don't block.
|
||||||
SECRET_ENV_PATTERN = re.compile(
|
SECRET_ENV_PATTERN = re.compile(
|
||||||
r"(.*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|APIKEY|API_KEY|AUTH).*)",
|
r"(.*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|APIKEY|API_KEY|AUTH).*)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@@ -58,7 +58,7 @@ def scan_environment_for_secrets() -> list[tuple[str, str]]:
|
|||||||
"""Return a list of ``(name, source)`` for env vars that look like secrets.
|
"""Return a list of ``(name, source)`` for env vars that look like secrets.
|
||||||
|
|
||||||
``source`` is ``"env"`` (a real environment variable). This is a
|
``source`` is ``"env"`` (a real environment variable). This is a
|
||||||
startup warning only — it does not modify the environment. The
|
startup warning only, it does not modify the environment. The
|
||||||
caller (the harness) decides whether to scrub them before starting
|
caller (the harness) decides whether to scrub them before starting
|
||||||
the sandbox container (see ``build_container_env``).
|
the sandbox container (see ``build_container_env``).
|
||||||
"""
|
"""
|
||||||
@@ -74,9 +74,9 @@ def audit_system_prompt(prompt: str) -> list[str]:
|
|||||||
|
|
||||||
Returns a list of warnings (empty if clean). This catches:
|
Returns a list of warnings (empty if clean). This catches:
|
||||||
|
|
||||||
- ``f"... {os.environ['API_KEY']} ..."`` — interpolating env
|
- ``f"... {os.environ['API_KEY']} ..."``: interpolating env
|
||||||
vars directly into the prompt.
|
vars directly into the prompt.
|
||||||
- ``f"... {os.getenv('SECRET')} ..."`` — same, via getenv.
|
- ``f"... {os.getenv('SECRET')} ..."``: same, via getenv.
|
||||||
- Literal occurrences of known secret-looking env-var names.
|
- Literal occurrences of known secret-looking env-var names.
|
||||||
"""
|
"""
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
@@ -84,7 +84,7 @@ def audit_system_prompt(prompt: str) -> list[str]:
|
|||||||
if PROMPT_INTERPOLATION_PATTERN.search(prompt):
|
if PROMPT_INTERPOLATION_PATTERN.search(prompt):
|
||||||
warnings.append(
|
warnings.append(
|
||||||
"System prompt appears to interpolate os.environ / os.getenv. "
|
"System prompt appears to interpolate os.environ / os.getenv. "
|
||||||
"Never put secrets in the system prompt — the model can leak "
|
"Never put secrets in the system prompt, the model can leak "
|
||||||
"them in tool calls or responses."
|
"them in tool calls or responses."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ def audit_system_prompt(prompt: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5.2 Credential injection at the harness level
|
# Credential injection at the harness level
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Host paths that must NEVER be bind-mounted into the sandbox container.
|
# Host paths that must NEVER be bind-mounted into the sandbox container.
|
||||||
@@ -130,8 +130,8 @@ ALLOWED_CONTAINER_ENV = frozenset({
|
|||||||
"LANG",
|
"LANG",
|
||||||
"LC_ALL",
|
"LC_ALL",
|
||||||
"TERM",
|
"TERM",
|
||||||
"AGENT_SESSION_ID", # set per-session by the harness (§5.3)
|
"AGENT_SESSION_ID", # set per-session by the harness
|
||||||
"AGENT_SESSION_TOKEN", # short-lived, per-session (§5.3)
|
"AGENT_SESSION_TOKEN", # short-lived, per-session
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ def build_container_env(session_id: str, session_token: str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5.3 Rotate credentials per session
|
# Rotate credentials per session
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class SessionCredentials:
|
class SessionCredentials:
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""Session-level abort & kill switches (checklist §6.3).
|
"""Session-level abort & kill switches.
|
||||||
|
|
||||||
Three pieces:
|
Three pieces:
|
||||||
|
|
||||||
1. ``AbortController`` — a thread-safe flag set by a signal handler
|
1. ``AbortController``: a thread-safe flag set by a signal handler
|
||||||
(SIGINT / SIGTERM) or by any code path that detects a fatal
|
(SIGINT / SIGTERM) or by any code path that detects a fatal
|
||||||
condition (cost cap, iteration cap, user request).
|
condition (cost cap, iteration cap, user request).
|
||||||
|
|
||||||
2. ``FileRollback`` — snapshots original file bytes before each
|
2. ``FileRollback``: snapshots original file bytes before each
|
||||||
write/edit so that an abort can offer to revert reversible state.
|
write/edit so that an abort can offer to revert reversible state.
|
||||||
|
|
||||||
3. ``kill_in_flight`` — a helper that sends SIGINT to any python
|
3. ``kill_in_flight``: a helper that sends SIGINT to any python
|
||||||
process running inside the sandbox container, stopping a hanging
|
process running inside the sandbox container, stopping a hanging
|
||||||
``docker exec`` without tearing down the container itself.
|
``docker exec`` without tearing down the container itself.
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ class AbortController:
|
|||||||
"""Register SIGINT / SIGTERM handlers that call ``trigger``.
|
"""Register SIGINT / SIGTERM handlers that call ``trigger``.
|
||||||
|
|
||||||
Only call this from the main thread (signal.signal requirement).
|
Only call this from the main thread (signal.signal requirement).
|
||||||
The previous handlers are not saved — we deliberately replace
|
The previous handlers are not saved, we deliberately replace
|
||||||
them because the abort controller is the final arbiter.
|
them because the abort controller is the final arbiter.
|
||||||
"""
|
"""
|
||||||
def _handler(signum, frame):
|
def _handler(signum, frame):
|
||||||
@@ -94,7 +94,7 @@ class AbortController:
|
|||||||
self._registered_signals.append(sig)
|
self._registered_signals.append(sig)
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
# Not in main thread, or signal not supported on this
|
# Not in main thread, or signal not supported on this
|
||||||
# platform — skip silently.
|
# platform, skip silently.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def remove_signal_handlers(self) -> None:
|
def remove_signal_handlers(self) -> None:
|
||||||
@@ -126,7 +126,7 @@ def kill_in_flight(container: str) -> None:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Best-effort: if the container is already gone or pkill isn't
|
# Best-effort: if the container is already gone or pkill isn't
|
||||||
# available, the exec subprocess's own timeout (§4.3) will
|
# available, the exec subprocess's own timeout will
|
||||||
# eventually clean up.
|
# eventually clean up.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -136,14 +136,14 @@ def kill_in_flight(container: str) -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class FileRollback:
|
class FileRollback:
|
||||||
"""Snapshot original file bytes before each write/edit (§6.3).
|
"""Snapshot original file bytes before each write/edit.
|
||||||
|
|
||||||
On abort, ``offer_rollback`` walks the snapshot list and restores
|
On abort, ``offer_rollback`` walks the snapshot list and restores
|
||||||
each file to its pre-edit state, prompting the user for
|
each file to its pre-edit state, prompting the user for
|
||||||
confirmation.
|
confirmation.
|
||||||
|
|
||||||
Only ``write_file`` and ``edit_file`` are reversible; ``run_bash``
|
Only ``write_file`` and ``edit_file`` are reversible; ``run_bash``
|
||||||
side effects (e.g. ``git commit``) are not — the audit log is the
|
side effects (e.g. ``git commit``) are not, the audit log is the
|
||||||
only record for those.
|
only record for those.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -154,7 +154,8 @@ class FileRollback:
|
|||||||
|
|
||||||
def _ensure_backup_dir(self) -> Path:
|
def _ensure_backup_dir(self) -> Path:
|
||||||
if self._backup_dir is None:
|
if self._backup_dir is None:
|
||||||
self._backup_dir = Path(__file__).resolve().parent / ".rollback_backups"
|
self._backup_dir = Path(
|
||||||
|
__file__).resolve().parent / ".rollback_backups"
|
||||||
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return self._backup_dir
|
return self._backup_dir
|
||||||
|
|
||||||
@@ -171,7 +172,7 @@ class FileRollback:
|
|||||||
shutil.copy2(p, backup)
|
shutil.copy2(p, backup)
|
||||||
self._snapshots.append((str(p.resolve()), backup))
|
self._snapshots.append((str(p.resolve()), backup))
|
||||||
except OSError:
|
except OSError:
|
||||||
# If we can't snapshot, we just can't roll back — don't
|
# If we can't snapshot, we just can't roll back, don't
|
||||||
# block the tool call.
|
# block the tool call.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tool permission & parameter-scoping policy.
|
"""Tool permission & parameter-scoping policy.
|
||||||
|
|
||||||
Implements checklist §2.2 (confirmation for destructive actions) and
|
Implements confirmation for destructive actions and
|
||||||
§2.3 (scope tool parameters) in one place so the rules are easy to
|
scope tool parameters in one place so the rules are easy to
|
||||||
audit and extend.
|
audit and extend.
|
||||||
|
|
||||||
Three layers, evaluated in order by ``check_tool_policy`` before the
|
Three layers, evaluated in order by ``check_tool_policy`` before the
|
||||||
@@ -42,14 +42,14 @@ DESTRUCTIVE_TOOLS = frozenset({
|
|||||||
|
|
||||||
# Tools that ALWAYS require explicit human confirmation, even under
|
# Tools that ALWAYS require explicit human confirmation, even under
|
||||||
# ``dangerouslySkipPermissions``. These are the irreversible / exfil
|
# ``dangerouslySkipPermissions``. These are the irreversible / exfil
|
||||||
# class — we refuse them outright (a denylist), not just prompt for them.
|
# class, we refuse them outright (a denylist), not just prompt for them.
|
||||||
#
|
#
|
||||||
# ``run_bash`` is not in this set as a whole; instead its *command* is
|
# ``run_bash`` is not in this set as a whole; instead its *command* is
|
||||||
# screened by the shell policy below. ``webfetch`` is also policy-
|
# screened by the shell policy below. ``webfetch`` is also policy-
|
||||||
# screened (SSRF). This set is reserved for tools where *any* call is
|
# screened (SSRF). This set is reserved for tools where *any* call is
|
||||||
# too dangerous to auto-run.
|
# too dangerous to auto-run.
|
||||||
ALWAYS_CONFIRM_TOOLS = frozenset({
|
ALWAYS_CONFIRM_TOOLS = frozenset({
|
||||||
# Currently empty — kept for future destructive tools (e.g. delete_file,
|
# Currently empty, kept for future destructive tools (e.g. delete_file,
|
||||||
# send_email). The shell policy handles the dangerous run_bash cases.
|
# send_email). The shell policy handles the dangerous run_bash cases.
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ SHELL_DENYLIST_BINARIES = frozenset({
|
|||||||
"docker", # sandbox escape / host control
|
"docker", # sandbox escape / host control
|
||||||
"sudo", "su", # privilege escalation
|
"sudo", "su", # privilege escalation
|
||||||
"nc", "netcat", "ncat", # reverse shells / exfil
|
"nc", "netcat", "ncat", # reverse shells / exfil
|
||||||
"curl", "wget", # exfil / SSRF — handled here AND in web policy
|
"curl", "wget", # exfil / SSRF: handled here AND in web policy
|
||||||
"chmod", "chown", # permission tampering
|
"chmod", "chown", # permission tampering
|
||||||
"mkfs", "dd", # destructive disk ops
|
"mkfs", "dd", # destructive disk ops
|
||||||
"shutdown", "reboot", "halt", "poweroff",
|
"shutdown", "reboot", "halt", "poweroff",
|
||||||
@@ -200,7 +200,7 @@ def check_shell_policy(command: str) -> tuple[bool, str | None]:
|
|||||||
try:
|
try:
|
||||||
tokens = shlex.split(command)
|
tokens = shlex.split(command)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Unparseable (e.g. unbalanced quotes) — let the shell itself
|
# Unparseable (e.g. unbalanced quotes), let the shell itself
|
||||||
# reject it, but flag for confirmation.
|
# reject it, but flag for confirmation.
|
||||||
return False, "Shell command could not be parsed (unbalanced quotes)."
|
return False, "Shell command could not be parsed (unbalanced quotes)."
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ def check_web_policy(url: str) -> tuple[bool, str | None]:
|
|||||||
if addr.is_loopback or addr.is_link_local or addr.is_multicast:
|
if addr.is_loopback or addr.is_link_local or addr.is_multicast:
|
||||||
return False, f"Blocked IP '{ip}' (loopback / link-local / multicast)."
|
return False, f"Blocked IP '{ip}' (loopback / link-local / multicast)."
|
||||||
if addr.is_private:
|
if addr.is_private:
|
||||||
return False, f"Blocked IP '{ip}' (RFC1918 private range — SSRF guard)."
|
return False, f"Blocked IP '{ip}' (RFC1918 private range: SSRF guard)."
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Invoked by the host as:
|
|||||||
|
|
||||||
The tool arguments are read from stdin as a JSON object; the result is
|
The tool arguments are read from stdin as a JSON object; the result is
|
||||||
printed to stdout. Only the *action* tools (the ones that touch the
|
printed to stdout. Only the *action* tools (the ones that touch the
|
||||||
filesystem, shell, or network) are dispatched here — the in-memory
|
filesystem, shell, or network) are dispatched here: the in-memory
|
||||||
planning tools stay on the host.
|
planning tools stay on the host.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ ISO timestamp and session id. The log is flushed after every record so
|
|||||||
that a crash still leaves a complete trail. The file alone is enough
|
that a crash still leaves a complete trail. The file alone is enough
|
||||||
to reconstruct what the agent did, in order.
|
to reconstruct what the agent did, in order.
|
||||||
|
|
||||||
§2.4: tool results are truncated to ``MAX_RESULT_BYTES`` in the log to
|
tool results are truncated to ``MAX_RESULT_BYTES`` in the log to
|
||||||
prevent unbounded growth; a SHA-256 and full length are stored alongside
|
prevent unbounded growth; a SHA-256 and full length are stored alongside
|
||||||
so the truncated entry is self-describing and tamper-evident.
|
so the truncated entry is self-describing and tamper-evident.
|
||||||
|
|
||||||
§6.1: dedicated events for every decision step —
|
dedicated events for every decision step,
|
||||||
``log_permission_decision``, ``log_llm_request``, ``log_llm_response``,
|
``log_permission_decision``, ``log_llm_request``, ``log_llm_response``,
|
||||||
``log_session_abort`` — give full forensic replay without guessing.
|
``log_session_abort``, give full forensic replay without guessing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -66,7 +66,8 @@ class AuditLog:
|
|||||||
"event": event,
|
"event": event,
|
||||||
}
|
}
|
||||||
record.update(fields)
|
record.update(fields)
|
||||||
self._fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
self._fh.write(json.dumps(
|
||||||
|
record, ensure_ascii=False, default=str) + "\n")
|
||||||
self._fh.flush()
|
self._fh.flush()
|
||||||
|
|
||||||
# -- convenience wrappers ------------------------------------------
|
# -- convenience wrappers ------------------------------------------
|
||||||
@@ -86,7 +87,8 @@ class AuditLog:
|
|||||||
args = json.loads(tc.function.arguments)
|
args = json.loads(tc.function.arguments)
|
||||||
except Exception:
|
except Exception:
|
||||||
args = tc.function.arguments
|
args = tc.function.arguments
|
||||||
calls.append({"id": tc.id, "name": tc.function.name, "args": args})
|
calls.append(
|
||||||
|
{"id": tc.id, "name": tc.function.name, "args": args})
|
||||||
self.log("assistant_message", content=content, tool_calls=calls)
|
self.log("assistant_message", content=content, tool_calls=calls)
|
||||||
|
|
||||||
def log_tool_result(
|
def log_tool_result(
|
||||||
@@ -116,8 +118,6 @@ class AuditLog:
|
|||||||
**_truncate_for_log(result),
|
**_truncate_for_log(result),
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- §6.1 decision-step logging ----------------------------------
|
|
||||||
|
|
||||||
def log_permission_decision(
|
def log_permission_decision(
|
||||||
self,
|
self,
|
||||||
tool: str,
|
tool: str,
|
||||||
@@ -126,7 +126,7 @@ class AuditLog:
|
|||||||
allowed: bool,
|
allowed: bool,
|
||||||
reason: str | None = None,
|
reason: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record a standalone permission decision (§6.1).
|
"""Record a standalone permission decision.
|
||||||
|
|
||||||
This is emitted *before* the tool runs (or is refused), so the
|
This is emitted *before* the tool runs (or is refused), so the
|
||||||
audit trail shows the decision and its reason even if the
|
audit trail shows the decision and its reason even if the
|
||||||
@@ -148,7 +148,7 @@ class AuditLog:
|
|||||||
token_estimate: int,
|
token_estimate: int,
|
||||||
has_tools: bool,
|
has_tools: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record that an LLM request is about to be sent (§6.1)."""
|
"""Record that an LLM request is about to be sent."""
|
||||||
self.log(
|
self.log(
|
||||||
"llm_request",
|
"llm_request",
|
||||||
model=model,
|
model=model,
|
||||||
@@ -165,7 +165,7 @@ class AuditLog:
|
|||||||
message_hash: str | None = None,
|
message_hash: str | None = None,
|
||||||
tool_call_count: int = 0,
|
tool_call_count: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record the LLM response metadata (§6.1).
|
"""Record the LLM response metadata.
|
||||||
|
|
||||||
``message_hash`` is a SHA-256 of the assistant message content
|
``message_hash`` is a SHA-256 of the assistant message content
|
||||||
so the full conversation can be verified for forensic replay
|
so the full conversation can be verified for forensic replay
|
||||||
@@ -181,7 +181,7 @@ class AuditLog:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def log_session_abort(self, reason: str) -> None:
|
def log_session_abort(self, reason: str) -> None:
|
||||||
"""Record that the session was aborted (§6.3)."""
|
"""Record that the session was aborted."""
|
||||||
self.log("session_abort", reason=reason)
|
self.log("session_abort", reason=reason)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ def ask_question(question: str) -> str:
|
|||||||
try:
|
try:
|
||||||
answer = input(" Your answer: ").strip()
|
answer = input(" Your answer: ").strip()
|
||||||
except EOFError:
|
except EOFError:
|
||||||
return "(no answer — EOF)"
|
return "(no answer, EOF)"
|
||||||
return answer if answer else "(no answer provided)"
|
return answer if answer else "(no answer provided)"
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ def get_tool_schemas():
|
|||||||
"and cannot reasonably infer it from context. "
|
"and cannot reasonably infer it from context. "
|
||||||
"Ask one focused question at a time. "
|
"Ask one focused question at a time. "
|
||||||
"Do not use this for progress updates or to confirm actions you can already "
|
"Do not use this for progress updates or to confirm actions you can already "
|
||||||
"take — only ask when you are genuinely blocked."
|
"take, only ask when you are genuinely blocked."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ read-only to the tool. Network egress can be disabled entirely with
|
|||||||
The container is started once per session and reused for every action
|
The container is started once per session and reused for every action
|
||||||
tool call (via ``docker exec``) to avoid per-call startup latency.
|
tool call (via ``docker exec``) to avoid per-call startup latency.
|
||||||
In-memory planning tools (todo, scratchpad, ask_question) are **not**
|
In-memory planning tools (todo, scratchpad, ask_question) are **not**
|
||||||
run in the container — their state would not survive between separate
|
run in the container, their state would not survive between separate
|
||||||
``docker exec`` processes — so they stay in-process on the host.
|
``docker exec`` processes, so they stay in-process on the host.
|
||||||
|
|
||||||
This module requires a Docker-compatible container CLI on the host:
|
This module requires a Docker-compatible container CLI on the host:
|
||||||
Docker, or Podman (which is auto-detected when ``docker info`` does not
|
Docker, or Podman (which is auto-detected when ``docker info`` does not
|
||||||
work — note that a shell ``alias docker=podman`` is **not** enough,
|
work, note that a shell ``alias docker=podman`` is **not** enough,
|
||||||
because the agent invokes the binary directly via ``subprocess`` without
|
because the agent invokes the binary directly via ``subprocess`` without
|
||||||
a shell). The runtime can also be forced with the ``$AGENT_DOCKER``
|
a shell). The runtime can also be forced with the ``$AGENT_DOCKER``
|
||||||
environment variable. On first use the ``agent-security-runner`` image
|
environment variable. On first use the ``agent-security-runner`` image
|
||||||
@@ -132,7 +132,7 @@ class DockerSandbox:
|
|||||||
self.image = image
|
self.image = image
|
||||||
self.network = network
|
self.network = network
|
||||||
self.exec_timeout = float(exec_timeout)
|
self.exec_timeout = float(exec_timeout)
|
||||||
# the harness — not the model — controls the container env.
|
# the harness, not the model, controls the container env.
|
||||||
# Only an allowlist of vars is inherited from the host; secret-
|
# Only an allowlist of vars is inherited from the host; secret-
|
||||||
# looking env vars are stripped before the container ever starts.
|
# looking env vars are stripped before the container ever starts.
|
||||||
self.container_env = container_env or {}
|
self.container_env = container_env or {}
|
||||||
@@ -221,7 +221,7 @@ class DockerSandbox:
|
|||||||
def run_tool(self, name: str, args: dict) -> str:
|
def run_tool(self, name: str, args: dict) -> str:
|
||||||
"""Execute *name* with *args* inside the container, return its output.
|
"""Execute *name* with *args* inside the container, return its output.
|
||||||
|
|
||||||
§4.3: enforces a per-call timeout (``self.exec_timeout``). A
|
enforces a per-call timeout (``self.exec_timeout``). A
|
||||||
timeout is reported back as a ``DockerSandboxError`` with a
|
timeout is reported back as a ``DockerSandboxError`` with a
|
||||||
clear message so the LLM knows not to retry blindly.
|
clear message so the LLM knows not to retry blindly.
|
||||||
"""
|
"""
|
||||||
@@ -241,7 +241,7 @@ class DockerSandbox:
|
|||||||
raise DockerSandboxError(
|
raise DockerSandboxError(
|
||||||
f"Tool '{name}' timed out after {self.exec_timeout:.0f}s. "
|
f"Tool '{name}' timed out after {self.exec_timeout:.0f}s. "
|
||||||
"The command did not finish in the allowed time. Do not "
|
"The command did not finish in the allowed time. Do not "
|
||||||
"retry the same call — adjust the approach or ask the user."
|
"retry the same call, adjust the approach or ask the user."
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
err = (proc.stderr or proc.stdout or "").strip()
|
err = (proc.stderr or proc.stdout or "").strip()
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def todo_update(id, content=None, status=None) -> str:
|
|||||||
if item["status"] == "in_progress" and retries > 0:
|
if item["status"] == "in_progress" and retries > 0:
|
||||||
if retries >= RETRY_LIMIT:
|
if retries >= RETRY_LIMIT:
|
||||||
return (
|
return (
|
||||||
f"Updated to do item {id} to in_progress — "
|
f"Updated to do item {id} to in_progress, "
|
||||||
f"but this is retry {retries} of {
|
f"but this is retry {retries} of {
|
||||||
RETRY_LIMIT} (retry limit reached). "
|
RETRY_LIMIT} (retry limit reached). "
|
||||||
f"Do not retry again. Escalate to the user instead."
|
f"Do not retry again. Escalate to the user instead."
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Lightweight JSON-Schema validator for tool inputs (checklist §3.1, §3.3).
|
"""Lightweight JSON-Schema validator for tool inputs.
|
||||||
|
|
||||||
We deliberately avoid a third-party dependency (``jsonschema`` /
|
We deliberately avoid a third-party dependency (``jsonschema`` /
|
||||||
``pydantic``) so the host-side validation needs no new install and no
|
``pydantic``) so the host-side validation needs no new install and no
|
||||||
@@ -12,7 +12,7 @@ JSON-Schema Draft 7 actually used by ``tools/registry.get_tool_schemas``:
|
|||||||
- ``minimum`` / ``maximum``
|
- ``minimum`` / ``maximum``
|
||||||
- ``minLength`` / ``maxLength``
|
- ``minLength`` / ``maxLength``
|
||||||
|
|
||||||
§3.3 (limit output scope) is enforced by the same schemas: bounds on
|
limit output scope is enforced by the same schemas: bounds on
|
||||||
``offset``, ``limit``, ``command`` length, ``content`` length, and a
|
``offset``, ``limit``, ``command`` length, ``content`` length, and a
|
||||||
rejection of absolute-path glob patterns are baked into the schemas and
|
rejection of absolute-path glob patterns are baked into the schemas and
|
||||||
therefore checked here.
|
therefore checked here.
|
||||||
@@ -43,7 +43,7 @@ def _check_type(value: Any, expected: str) -> str | None:
|
|||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return f"expected string, got {type(value).__name__}"
|
return f"expected string, got {type(value).__name__}"
|
||||||
elif expected == "integer":
|
elif expected == "integer":
|
||||||
# bool is a subclass of int — reject it explicitly.
|
# bool is a subclass of int, reject it explicitly.
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
return f"expected integer, got {type(value).__name__}"
|
return f"expected integer, got {type(value).__name__}"
|
||||||
elif expected == "boolean":
|
elif expected == "boolean":
|
||||||
@@ -58,7 +58,7 @@ def validate_args(args: dict[str, Any], schema: dict) -> tuple[bool, list[str]]:
|
|||||||
"""Validate *args* against a tool's JSON-Schema function spec.
|
"""Validate *args* against a tool's JSON-Schema function spec.
|
||||||
|
|
||||||
``schema`` is the inner ``{"type": "object", "properties": ...}``
|
``schema`` is the inner ``{"type": "object", "properties": ...}``
|
||||||
dict — i.e. ``tool["function"]["parameters"]``.
|
dict, i.e. ``tool["function"]["parameters"]``.
|
||||||
|
|
||||||
Returns ``(ok, errors)``.
|
Returns ``(ok, errors)``.
|
||||||
"""
|
"""
|
||||||
@@ -89,11 +89,11 @@ def validate_args(args: dict[str, Any], schema: dict) -> tuple[bool, list[str]]:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Bounded schemas (§3.3 limit output scope)
|
# Bounded schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def bounded_schemas(raw_schemas: list[dict]) -> list[dict]:
|
def bounded_schemas(raw_schemas: list[dict]) -> list[dict]:
|
||||||
"""Return a copy of *raw_schemas* with §3.3 bounds injected.
|
"""Return a copy of *raw_schemas* with bounds injected.
|
||||||
|
|
||||||
We mutate copies of the per-tool parameter schemas to add:
|
We mutate copies of the per-tool parameter schemas to add:
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ def bounded_schemas(raw_schemas: list[dict]) -> list[dict]:
|
|||||||
- ``run_bash.command``: minLength 1, maxLength 4096
|
- ``run_bash.command``: minLength 1, maxLength 4096
|
||||||
- ``webfetch.url``: maxLength 4096
|
- ``webfetch.url``: maxLength 4096
|
||||||
- ``glob_files.pattern``: reject absolute paths (pattern check
|
- ``glob_files.pattern``: reject absolute paths (pattern check
|
||||||
implemented in ``_validate_value`` via a custom constraint —
|
implemented in ``_validate_value`` via a custom constraint,
|
||||||
here we add ``format: relative-path`` which our validator
|
here we add ``format: relative-path`` which our validator
|
||||||
treats specially).
|
treats specially).
|
||||||
|
|
||||||
@@ -142,7 +142,8 @@ def bounded_schemas(raw_schemas: list[dict]) -> list[dict]:
|
|||||||
# would escape the working-dir scoping.
|
# would escape the working-dir scoping.
|
||||||
props.setdefault("pattern", {})["format"] = "relative-path"
|
props.setdefault("pattern", {})["format"] = "relative-path"
|
||||||
|
|
||||||
out.append({"type": "function", "function": {**fn, "parameters": params}})
|
out.append({"type": "function", "function": {
|
||||||
|
**fn, "parameters": params}})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -181,12 +182,15 @@ def _validate_value(value: Any, schema: dict, path: str) -> list[str]:
|
|||||||
|
|
||||||
if schema.get("type") == "string":
|
if schema.get("type") == "string":
|
||||||
if "minLength" in schema and len(value) < schema["minLength"]:
|
if "minLength" in schema and len(value) < schema["minLength"]:
|
||||||
errs.append(f"{path}: length {len(value)} < minLength {schema['minLength']}")
|
errs.append(f"{path}: length {len(value)} < minLength {
|
||||||
|
schema['minLength']}")
|
||||||
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
||||||
errs.append(f"{path}: length {len(value)} > maxLength {schema['maxLength']}")
|
errs.append(f"{path}: length {len(value)} > maxLength {
|
||||||
# §3.3 custom format: relative-path (reject absolute glob patterns).
|
schema['maxLength']}")
|
||||||
|
# custom format: relative-path (reject absolute glob patterns).
|
||||||
if schema.get("format") == "relative-path" and value.startswith("/"):
|
if schema.get("format") == "relative-path" and value.startswith("/"):
|
||||||
errs.append(f"{path}: absolute paths are not allowed here (must be relative)")
|
errs.append(
|
||||||
|
f"{path}: absolute paths are not allowed here (must be relative)")
|
||||||
|
|
||||||
if schema.get("type") == "integer":
|
if schema.get("type") == "integer":
|
||||||
if "minimum" in schema and value < schema["minimum"]:
|
if "minimum" in schema and value < schema["minimum"]:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def agent_loop(client):
|
|||||||
messages.append(message)
|
messages.append(message)
|
||||||
|
|
||||||
if message.tool_calls:
|
if message.tool_calls:
|
||||||
# The LLM wants to use one or more tools — run them, then loop
|
# The LLM wants to use one or more tools, run them, then loop
|
||||||
handle_tool_calls(message.tool_calls, messages)
|
handle_tool_calls(message.tool_calls, messages)
|
||||||
else:
|
else:
|
||||||
# No tool calls: we have the final answer
|
# No tool calls: we have the final answer
|
||||||
|
|||||||
Reference in New Issue
Block a user