fix: error handling, sandbox env, file tool enforcement

- tools/file_*: hard-fail when OLLIE_SESSION_ID is unset; surface errors
  on all failure paths including dir checks, marker cleanup, and I/O
- tools/file_read: treat nonexistent path as new file (emit "(new file)",
  write empty marker) so file_write can create new files
- tools/file_write: colorize new-file lines with + (U+FF0B)
- tools/file_glob, file_grep, memory_*, task_*: consistent error handling
  and output to stdout so the model sees all failures
- tui/diffcolor.go: match +/− (U+FF0B/U+2212) lookalikes emitted by
  file_edit/file_write; fix +++ / --- header pattern
- prompts: prohibit execute_code for file creation/modification;
  enforce execute_tool with file_write/file_edit instead
- core submodule: surface isError from MCP responses; inject
  OLLIE_SESSION_ID into execute server at session creation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-14 22:52:10 +02:00
parent f155ba91d0
commit 8ca905799b
12 changed files with 122 additions and 28 deletions

2
core

@ -1 +1 @@
Subproject commit 64d3915593c9600911cf332b9f0d42bc8a4e2d5d
Subproject commit 655b9e706045f503b4889c6bee6e43261b50f4f0

View File

@ -1,5 +1,7 @@
You are ollie, an agentic assistant.
**Critical rule: never use `execute_code` to create or modify files. Always use `execute_tool` with `file_write` or `file_edit`. No exceptions.**
# Tone and style
- Format output as markdown. The frontend may render it as HTML, display it in a terminal, or show it as plain text — never rely on ANSI escape codes or terminal-specific formatting.
@ -20,6 +22,7 @@ Be truthful. If the user is wrong, say so — do not soften, hedge, or dance aro
# Tool preferences
- Make independent tool calls in parallel when there are no dependencies between them.
- **Never use `execute_code` to create or modify files.** Use `execute_tool` with `file_write` or `file_edit` instead. This is a hard rule — there are no exceptions for "simple" files.
# Environment

View File

@ -18,7 +18,7 @@ Using `cat`, `sed`, `awk`, `grep`, `find`, `echo >`, or similar shell commands d
- **Read before writing.** Always call `file_read` on a file before `file_write` or `file_edit`. This is enforced — writes will fail without a prior read.
- **Prefer `file_edit` over `file_write`** for partial changes.
- **Never use `execute_code` or `execute_tool` for file I/O.** Reserve those for operations that genuinely require a shell: building, testing, git, process control.
- **To create or modify a file with content you provide, always use `execute_tool` with `file_write` or `file_edit` — never `execute_code`.** Capturing process output to a file (e.g. `make > make.log`, `openssl genrsa > key.pem`) is fine.
## Examples

View File

@ -8,14 +8,27 @@ import hashlib
import difflib
def marker_path(file_path):
sid = os.environ.get('OLLIE_SESSION_ID', '')
if not sid:
print("error: OLLIE_SESSION_ID is not set")
sys.exit(1)
h = hashlib.sha256(file_path.encode()).hexdigest()[:64]
sid = os.environ.get('OLLIE_SESSION_ID', '_')
return f"/tmp/ollie/{sid}/{h}"
def unified_diff(path, old, new):
old_lines = old.splitlines(keepends=True)
new_lines = new.splitlines(keepends=True)
return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path))
raw = ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path))
# Replace leading +/- with Unicode lookalikes so only the colorizer's
# explicit patterns trigger, not incidental ASCII +/- in content.
out = []
for line in raw.splitlines(keepends=True):
if line.startswith('+'):
line = '+' + line[1:]
elif line.startswith('-'):
line = '−' + line[1:]
out.append(line)
return ''.join(out)
def find_exact(content, find):
return find if find in content else None
@ -124,7 +137,11 @@ except OSError as e:
print(f"error: {e}")
sys.exit(1)
os.unlink(marker)
try:
os.unlink(marker)
except OSError as e:
print(f"error: failed to remove marker: {e}")
sys.exit(1)
diff = unified_diff(file_path, original, new_content)
print(diff if diff else "(no changes)", end='' if diff else '\n')

View File

@ -7,8 +7,23 @@ search_path="${2:-$PWD}"
[ -z "$pattern" ] && echo "usage: file_glob <pattern> [path]" && exit 1
if [ ! -d "$search_path" ]; then
echo "error: path is not a directory: $search_path"
exit 1
fi
# collect matches and sort by mtime descending
rg --no-config --files -g "$pattern" "$search_path" 2>/dev/null \
| xargs -d '\n' stat --printf '%Y\t%n\n' 2>/dev/null \
rg_out=$(rg --no-config --files -g "$pattern" "$search_path" 2>&1)
rg_exit=$?
if [ $rg_exit -gt 1 ]; then
echo "error: $rg_out"
exit 1
fi
if [ -z "$rg_out" ]; then
echo "no matches"
exit 0
fi
echo "$rg_out" \
| xargs -d '\n' stat --printf '%Y\t%n\n' 2>&1 \
| sort -rn \
| cut -f2-

View File

@ -57,9 +57,10 @@ cmd += ['-e', args.pattern, search_path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 1:
sys.exit(0) # no matches
print("no matches")
sys.exit(0)
if result.returncode > 1:
print(result.stderr.strip() or f"rg exited with {result.returncode}", file=sys.stderr)
print(f"error: {result.stderr.strip() or f'rg exited with {result.returncode}'}")
sys.exit(1)
lines = result.stdout.splitlines()

View File

@ -10,8 +10,21 @@ max_size=$((8 * 1024 * 1024))
[ -z "$file_path" ] && echo "usage: file_read <file_path> [offset] [limit]" && exit 1
[[ "$file_path" != /* ]] && echo "error: path must be absolute, got: $file_path" && exit 1
[ ! -e "$file_path" ] && echo "error: file not found: $file_path" && exit 1
[ ! -f "$file_path" ] && echo "error: not a regular file: $file_path" && exit 1
[ ! -f "$file_path" ] && [ -e "$file_path" ] && echo "error: not a regular file: $file_path" && exit 1
if [ -z "$OLLIE_SESSION_ID" ]; then
echo "error: OLLIE_SESSION_ID is not set"
exit 1
fi
mkdir -p "/tmp/ollie/$OLLIE_SESSION_ID"
marker="/tmp/ollie/$OLLIE_SESSION_ID/$(printf '%s' "$file_path" | sha256sum | cut -c1-64)"
if [ ! -e "$file_path" ]; then
echo "(new file)"
: > "$marker"
exit 0
fi
size=$(wc -c < "$file_path")
[ "$size" -gt "$max_size" ] && echo "error: file too large ($size bytes, max $max_size)" && exit 1
@ -31,6 +44,4 @@ else
fi
# save content to marker (used by file_edit/file_write as staging area)
mkdir -p "/tmp/ollie/$OLLIE_SESSION_ID"
marker="/tmp/ollie/$OLLIE_SESSION_ID/$(printf '%s' "$file_path" | sha256sum | cut -c1-64)"
cp "$file_path" "$marker"

View File

@ -8,8 +8,11 @@ import hashlib
import difflib
def marker_path(file_path):
sid = os.environ.get('OLLIE_SESSION_ID', '')
if not sid:
print("error: OLLIE_SESSION_ID is not set")
sys.exit(1)
h = hashlib.sha256(file_path.encode()).hexdigest()[:64]
sid = os.environ.get('OLLIE_SESSION_ID', '_')
return f"/tmp/ollie/{sid}/{h}"
def unified_diff(path, old, new):
@ -39,6 +42,11 @@ if exists:
with open(marker, 'r', errors='replace') as f:
old_content = f.read()
parent = os.path.dirname(file_path)
if parent and not os.path.isdir(parent):
print(f"error: parent directory does not exist: {parent}")
sys.exit(1)
try:
with open(marker, 'w') as f:
f.write(content)
@ -48,11 +56,15 @@ except OSError as e:
print(f"error: {e}")
sys.exit(1)
os.unlink(marker)
try:
os.unlink(marker)
except OSError as e:
print(f"error: failed to remove marker: {e}")
sys.exit(1)
if not exists:
for line in content.splitlines():
print(f"+{line}")
print(f"+{line}")
else:
diff = unified_diff(file_path, old_content, content)
print(diff if diff else "(no changes)", end='' if diff else '\n')

View File

@ -7,10 +7,20 @@
mount="${OLLIE:-$HOME/mnt/ollie}"
mem_dir="$mount/m"
if [ ! -d "$mem_dir" ]; then
echo "error: memory directory does not exist: $mem_dir"
exit 1
fi
query="$*"
# Search filenames (title/tag match) and file bodies
matches=$(grep -ril "$query" "$mem_dir" 2>/dev/null)
matches=$(grep -ril "$query" "$mem_dir" 2>&1)
grep_exit=$?
if [ $grep_exit -gt 1 ]; then
echo "error: $matches"
exit 1
fi
if [ -z "$matches" ]; then
echo "no matches for: $query"
@ -19,6 +29,6 @@ fi
while IFS= read -r file; do
echo "=== $(basename "$file") ==="
cat "$file"
cat "$file" || echo "error: could not read $file"
echo
done <<< "$matches"

View File

@ -18,6 +18,10 @@ body = sys.argv[3]
mount = os.environ.get("OLLIE") or os.path.expanduser("~/mnt/ollie")
mem_dir = os.path.join(mount, "m")
if not os.path.isdir(mem_dir):
print(f"error: memory directory does not exist: {mem_dir}")
sys.exit(1)
now = datetime.now()
ident = now.strftime("%Y%m%dT%H%M%S")
date = now.strftime("%Y-%m-%d %a %H:%M")

View File

@ -4,7 +4,7 @@
import sys, os, re
if len(sys.argv) < 3:
print("usage: task_complete <plan-file> <step-substring>", file=sys.stderr)
print("usage: task_complete <plan-file> <step-substring>", file=sys.stdout)
sys.exit(1)
ollie = os.environ.get('OLLIE', os.path.expanduser('~/mnt/ollie'))
@ -14,8 +14,16 @@ plan_dir = os.path.join(ollie, 'pl')
filepath = os.path.join(plan_dir, os.path.basename(sys.argv[1]))
substring = sys.argv[2].lower()
with open(filepath) as f:
lines = f.readlines()
if not os.path.exists(filepath):
print(f"error: plan file not found: {filepath}", file=sys.stdout)
sys.exit(1)
try:
with open(filepath) as f:
lines = f.readlines()
except OSError as e:
print(f"error: {e}", file=sys.stdout)
sys.exit(1)
matched = False
for i, line in enumerate(lines):
@ -25,7 +33,7 @@ for i, line in enumerate(lines):
break
if not matched:
print(f"no unchecked step matching {sys.argv[2]!r}", file=sys.stderr)
print(f"no unchecked step matching {sys.argv[2]!r}", file=sys.stdout)
sys.exit(1)
remaining = [line[6:].strip() for line in lines if re.match(r'^- \[ \] ', line)]
@ -40,11 +48,19 @@ elif not remaining and '__wip' in filepath:
else:
newpath = filepath
with open(filepath, 'w') as f:
f.writelines(lines)
try:
with open(filepath, 'w') as f:
f.writelines(lines)
except OSError as e:
print(f"error: failed to write {filepath}: {e}", file=sys.stdout)
sys.exit(1)
if newpath != filepath:
os.rename(filepath, newpath)
try:
os.rename(filepath, newpath)
except OSError as e:
print(f"error: failed to rename to {newpath}: {e}", file=sys.stdout)
sys.exit(1)
if remaining:
print('\n'.join(f'- {s}' for s in remaining))

View File

@ -8,14 +8,19 @@ OLLIE="${OLLIE:-$HOME/mnt/ollie}"
PLAN_DIR="$OLLIE/pl"
if [ $# -eq 0 ]; then
echo "usage: task_plan <goal> <step1> [step2] ..." >&2
echo "usage: task_plan <goal> <step1> [step2] ..."
exit 1
fi
GOAL="$1"
shift
if [ $# -eq 0 ]; then
echo "error: at least one step required" >&2
echo "error: at least one step required"
exit 1
fi
if [ ! -d "$PLAN_DIR" ]; then
echo "error: plan directory does not exist: $PLAN_DIR"
exit 1
fi
@ -36,6 +41,6 @@ FILEPATH="$PLAN_DIR/$FILENAME"
for step in "$@"; do
printf '- [ ] %s\n' "$step"
done
} > "$FILEPATH"
} > "$FILEPATH" || { echo "error: failed to write $FILEPATH"; exit 1; }
echo "$FILENAME"