From 5f174918c52face711b4a33808ed5eb661c3f7d4 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 12 Nov 2026 23:10:53 -0700 Subject: [PATCH] Add agent skills component (component 7) - Discover folder skills (skills//SKILL.md or skill//SKILL.md) with name/description/triggers and optional context/resources files - Stdlib TF-IDF + trigger-phrase similarity check to auto-select a skill; explicit /skill and a use_skill tool for mid-run activation - Inject active skill instructions and preloaded files into the prompt - Log prompt, similarity scores and executed skill to skills.jsonl + stderr - Ship vacuum-world-agent (Lab 5) and pr-review skills - Add tests, SDLC docs (intent, spec, plan, tests, verification) and the recorded Lab 4 run solved with the Lab 5 skill Co-Authored-By: Claude Opus 5.5 --- README.md | 46 ++ lab4_skill_run/README.md | 2 + lab4_skill_run/RUN.md | 21 + lab4_skill_run/Test/test.py | 132 ++++++ lab4_skill_run/agent_strategy1.py | 223 ++++++++++ lab4_skill_run/logs/console_stderr.txt | 4 + lab4_skill_run/logs/console_stdout.txt | 17 + .../logs/earlier_iterations/run1_stderr.txt | 4 + .../logs/earlier_iterations/run2_session.json | 120 +++++ lab4_skill_run/logs/session.json | 103 +++++ lab4_skill_run/logs/skills.jsonl | 4 + lab4_skill_run/spec.md | 189 ++++++++ mini_coding_agent.py | 421 +++++++++++++++++- pyproject.toml | 3 + sdlc/01-intent.md | 62 +++ sdlc/02-spec.md | 141 ++++++ sdlc/03-plan.md | 49 ++ sdlc/04-tests.md | 47 ++ sdlc/05-verification.md | 78 ++++ skills/pr-review/SKILL.md | 22 + skills/vacuum-world-agent/SKILL.md | 61 +++ skills/vacuum-world-agent/reference.md | 103 +++++ tests/test_skills.py | 307 +++++++++++++ 23 files changed, 2148 insertions(+), 11 deletions(-) create mode 100644 lab4_skill_run/README.md create mode 100644 lab4_skill_run/RUN.md create mode 100644 lab4_skill_run/Test/test.py create mode 100644 lab4_skill_run/agent_strategy1.py create mode 100644 lab4_skill_run/logs/console_stderr.txt create mode 100644 lab4_skill_run/logs/console_stdout.txt create mode 100644 lab4_skill_run/logs/earlier_iterations/run1_stderr.txt create mode 100644 lab4_skill_run/logs/earlier_iterations/run2_session.json create mode 100644 lab4_skill_run/logs/session.json create mode 100644 lab4_skill_run/logs/skills.jsonl create mode 100644 lab4_skill_run/spec.md create mode 100644 sdlc/01-intent.md create mode 100644 sdlc/02-spec.md create mode 100644 sdlc/03-plan.md create mode 100644 sdlc/04-tests.md create mode 100644 sdlc/05-verification.md create mode 100644 skills/pr-review/SKILL.md create mode 100644 skills/vacuum-world-agent/SKILL.md create mode 100644 skills/vacuum-world-agent/reference.md create mode 100644 tests/test_skills.py diff --git a/README.md b/README.md index 78c1767..f8b6ea8 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,52 @@ This coding harness is organized around six practical building blocks: 6. **Delegation and bounded subagents** Scoped subtasks can be delegated to helper agents that inherit enough context to help (but operate within limits). +  +## Component 7: Agent Skills (CS440 addition) + +7. **Agent skills** + Reusable, folder-based instructions. Each skill is a folder with a `SKILL.md` + (front matter `name`, `description`, `triggers`, optional `context` / `resources` + files to preload, + step-by-step instructions). + The agent discovers them, runs a similarity check on every prompt, and injects + the selected skill's instructions into the prompt. The model can also load a + skill mid-task with the `use_skill` tool, and the user can force one with + `/skill `. + +``` +skills/ +├── pr-review/SKILL.md +└── vacuum-world-agent/ # Lab 5 skill + ├── SKILL.md + └── reference.md +``` + +Skills are searched in `--skills-dir`, `/skills`, `/skill`, +`/skills`, `/skill`, and the `skills/` folder next to +`mini_coding_agent.py`. + +**Similarity check:** TF-IDF cosine between the prompt and each skill's +name/description/triggers, plus a bonus when a trigger phrase appears verbatim: +`score = 0.6 * cosine + 0.4 * trigger_hit`. The best skill is used if +`score >= --skill-threshold` (default `0.20`). + +**Logging:** every prompt, similarity check (all scores) and executed skill is +written to `.mini-coding-agent/logs/skills.jsonl` and echoed to stderr: + +``` +[skills] discovered 2 skill(s): pr-review, vacuum-world-agent +[skills] prompt: 'Solve Lab 4: implement the agent described in spec.md ...' +[skills] similarity (threshold 0.20): vacuum-world-agent=0.590, pr-review=0.011 -> selected: vacuum-world-agent +[skills] executing skill 'vacuum-world-agent' (trigger=auto, score=0.590) from .../skills/vacuum-world-agent/SKILL.md; loaded files: spec.md, Test/test.py, vacuum-world-agent/reference.md +``` + +Flags: `--skills-dir PATH` (repeatable), `--skill-threshold FLOAT`, `--no-skills`. +REPL: `/skills`, `/skill `. + +The SDLC documents for this component are in [`sdlc/`](sdlc/) +(intent → spec → plan → tests → verification), and the Lab 4 run that used +the Lab 5 skill is recorded in [`lab4_skill_run/`](lab4_skill_run/). +   ## Requirements diff --git a/lab4_skill_run/README.md b/lab4_skill_run/README.md new file mode 100644 index 0000000..f81bd21 --- /dev/null +++ b/lab4_skill_run/README.md @@ -0,0 +1,2 @@ +# Lab 4 - Vacuum World (Strategy 1) +Implement the agent described in spec.md. Tests live in Test/. diff --git a/lab4_skill_run/RUN.md b/lab4_skill_run/RUN.md new file mode 100644 index 0000000..58b2757 --- /dev/null +++ b/lab4_skill_run/RUN.md @@ -0,0 +1,21 @@ +# Lab 4 solved by the mini agent using the Lab 5 skill + +This folder is the workspace after the final run (run 3) described in +[`../sdlc/05-verification.md`](../sdlc/05-verification.md). + +| File | Origin | +|------|--------| +| `spec.md`, `Test/test.py` | copied from `Lab4-PersistentMemory-CS440` (input) | +| `README.md` | two-line workspace README (input) | +| `agent_strategy1.py` | **written by the mini agent** (qwen3.5:4b) | +| `logs/skills.jsonl` | skill component log: discover → prompt → similarity_check → skill_executed | +| `logs/console_stderr.txt` | `[skills]` console log lines | +| `logs/console_stdout.txt` | agent banner + final answer | +| `logs/session.json` | full transcript (user, skill activation, tool calls, final) | +| `logs/earlier_iterations/` | evidence from runs 1 and 2 | + +Check it: + +```bash +python -m pytest -q Test/test.py +``` diff --git a/lab4_skill_run/Test/test.py b/lab4_skill_run/Test/test.py new file mode 100644 index 0000000..2783674 --- /dev/null +++ b/lab4_skill_run/Test/test.py @@ -0,0 +1,132 @@ +import os +import sys + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from agent_strategy1 import PersistentMemoryAgent + +class DummyVacuumWorld: + """Minimal environment matching the lab's percept contract.""" + + def __init__(self): + self.grid = { + (0, 0): "start", + (0, 1): 0, + (0, 2): 0, + (0, 3): 0, + (1, 0): 0, + (1, 1): "X", + (1, 2): 0, + (1, 3): 0, + (2, 0): 0, + (2, 1): 0, + (2, 2): 0, + (2, 3): 0, + (3, 0): 0, + (3, 1): -1, + (3, 2): 0, + (3, 3): 10, + } + self.obstacles = {(1, 1)} + self.dirty = {(0, 1), (2, 2), (3, 1)} + self.pos = (0, 0) + + def percept(self): + pos = self.pos + cell = self.grid.get(pos, 0) + dirty = pos in self.dirty + neighbors = {} + for direction, delta in { + "UP": (-1, 0), + "DOWN": (1, 0), + "LEFT": (0, -1), + "RIGHT": (0, 1), + }.items(): + next_row = pos[0] + delta[0] + next_col = pos[1] + delta[1] + coord = (next_row, next_col) + if next_row < 0 or next_row >= 4 or next_col < 0 or next_col >= 4: + neighbors[direction] = None + elif coord in self.obstacles: + neighbors[direction] = None + else: + neighbors[direction] = self.grid.get(coord, 0) + return {"pos": pos, "cell": cell, "dirty": dirty, "neighbors": neighbors} + + +def test_memory_schema_is_initialized(): + agent = PersistentMemoryAgent(DummyVacuumWorld()) + + assert set(agent.memory) == { + "visited", + "obstacles", + "rewards", + "dirty_known", + "clean_known", + "last_pos", + "last_action", + "step_count", + "history", + } + assert agent.memory["visited"] == set() + assert agent.memory["obstacles"] == set() + assert agent.memory["rewards"] == {} + assert agent.memory["dirty_known"] == set() + assert agent.memory["clean_known"] == set() + assert agent.memory["last_pos"] is None + assert agent.memory["last_action"] is None + assert agent.memory["step_count"] == 0 + assert agent.memory["history"] == [] + + +def test_update_memory_records_obstacles_and_dirty_state(): + env = DummyVacuumWorld() + env.pos = (1, 0) + agent = PersistentMemoryAgent(env) + percept = env.percept() + + agent.update_memory(percept) + + assert (1, 0) in agent.memory["visited"] + assert (1, 1) in agent.memory["obstacles"] + assert (1, 0) in agent.memory["clean_known"] + assert len(agent.memory["history"]) >= 1 + assert agent.memory["step_count"] == 1 + + +def test_agent_sucks_when_current_cell_is_dirty(): + env = DummyVacuumWorld() + env.pos = (0, 1) + agent = PersistentMemoryAgent(env) + percept = env.percept() + + action = agent.program(percept) + + assert action == "SUCK" + + +def test_agent_avoids_known_obstacles_and_moves_legally(): + env = DummyVacuumWorld() + env.pos = (0, 0) + agent = PersistentMemoryAgent(env) + percept = env.percept() + agent.update_memory(percept) + + action = agent.program(percept) + + assert action in {"DOWN", "RIGHT"} + assert action != "UP" + assert action != "LEFT" + + +def test_bfs_next_step_for_reachable_target(): + env = DummyVacuumWorld() + agent = PersistentMemoryAgent(env) + agent.memory["obstacles"] = {(1, 1)} + + assert agent._bfs_next_step((0, 0), (0, 3)) == "RIGHT" + assert agent._bfs_next_step((0, 0), (3, 0)) == "DOWN" \ No newline at end of file diff --git a/lab4_skill_run/agent_strategy1.py b/lab4_skill_run/agent_strategy1.py new file mode 100644 index 0000000..1c9765a --- /dev/null +++ b/lab4_skill_run/agent_strategy1.py @@ -0,0 +1,223 @@ +"""Persistent Memory Vacuum Agent for Lab 4.""" + +from collections import deque + +DIRECTIONS = { + "UP": (-1, 0), + "DOWN": (1, 0), + "LEFT": (0, -1), + "RIGHT": (0, 1), +} +GRID_SIZE = 4 + + +class PersistentMemoryAgent: + """Vacuum world agent using persistent memory architecture.""" + + def __init__(self, env): + """Initialize the agent with environment reference and memory schema.""" + self.env = env + self.memory = { + "visited": set(), # (r,c) cells the robot has stood on + "obstacles": set(), # (r,c) cells known to be 'X' / OOB + "rewards": {}, # (r,c) -> -1 or 10, observed values + "dirty_known": set(), # (r,c) cells observed dirty and not yet sucked + "clean_known": set(), # (r,c) cells observed clean after SUCK + "last_pos": None, # (r,c) of previous step, for loop detection + "last_action": None, # str, previous action + "step_count": 0, # int, monotonic step counter + "history": [], # list of (step, pos, action, note) log entries + } + + def _log(self, pos, note): + """Append a history entry and print a trace line.""" + step = self.memory["step_count"] + self.memory["history"].append((step, pos, self.memory["last_action"], note)) + print(f"[step {step}] pos={pos} action={self.memory['last_action']} -> {note}") + + def _neighbor_coord(self, pos, direction): + """Return the (r, c) one step in direction, or None if out of bounds.""" + dr, dc = DIRECTIONS[direction] + r, c = pos[0] + dr, pos[1] + dc + if 0 <= r < GRID_SIZE and 0 <= c < GRID_SIZE: + return (r, c) + return None + + def _bfs_next_step(self, start, goal): + """Return the first action of a BFS path from start to goal, or None.""" + if start == goal: + return None + queue = deque([(start, None)]) + seen = {start} + while queue: + pos, first = queue.popleft() + for direction in ("UP", "DOWN", "LEFT", "RIGHT"): + nxt = self._neighbor_coord(pos, direction) + if nxt is None or nxt in seen or nxt in self.memory["obstacles"]: + continue + step = first or direction + if nxt == goal: + return step + seen.add(nxt) + queue.append((nxt, step)) + return None + + def update_memory(self, percept): + """Merge percept into memory with explicit update blocks.""" + pos = percept["pos"] + + # --- 1. Record current position as visited --- + if pos not in self.memory["visited"]: + self.memory["visited"].add(pos) + self._log(pos, "visited") + + # --- 2. Record obstacles from neighbor senses --- + for direction, cell in percept["neighbors"].items(): + nbr = self._neighbor_coord(pos, direction) + if cell is None and nbr is not None: + if nbr not in self.memory["obstacles"]: + self.memory["obstacles"].add(nbr) + self._log(pos, f"sensed obstacle at {nbr}") + + # --- 3. Record rewards observed --- + for direction, cell in percept["neighbors"].items(): + nbr = self._neighbor_coord(pos, direction) + if cell in (-1, 10) and nbr is not None: + self.memory["rewards"][nbr] = cell + self._log(pos, f"recorded reward {cell} at {nbr}") + + # --- 4. Track dirty / clean --- + if percept["dirty"]: + self.memory["dirty_known"].add(pos) + self._log(pos, "observed dirty cell") + else: + self.memory["dirty_known"].discard(pos) + self.memory["clean_known"].add(pos) + self._log(pos, "observed clean cell") + + # --- 5. Update step log AFTER using last_pos/last_action --- + self.memory["history"].append( + (self.memory["step_count"], pos, + self.memory["last_action"], "memory updated") + ) + self.memory["last_pos"] = pos + self.memory["step_count"] += 1 + + def program(self, percept): + """Return the next action using memory + percept.""" + pos = percept["pos"] + self.update_memory(percept) + + # Rule 1: current cell is dirty -> SUCK + if percept["dirty"]: + action = "SUCK" + self._log(pos, "chose SUCK (reason: current cell dirty)") + # Rule 2: move toward a known dirty cell + elif self.memory["dirty_known"]: + goal = None + for d in ("UP", "DOWN", "LEFT", "RIGHT"): + nxt = self._neighbor_coord(pos, d) + if nxt in self.memory["dirty_known"] and goal is None: + goal = nxt + break + if goal is not None: + action = self._bfs_next_step(pos, goal) + if action is not None: + self._log(pos, f"chose {action} (reason: nearest dirty at {goal})") + else: + action = "NO-OP" + # Rule 3: explore an unvisited, open neighbor + elif self.memory["visited"]: + for d in ("UP", "DOWN", "LEFT", "RIGHT"): + nxt = self._neighbor_coord(pos, d) + if nxt is not None and nxt not in self.memory["visited"] and nxt not in self.memory["obstacles"]: + action = d + self._log(pos, f"chose {d} (reason: unvisited neighbor)") + break + # Rule 4: BFS toward nearest unvisited known-safe cell + else: + goal = None + for d in ("UP", "DOWN", "LEFT", "RIGHT"): + nxt = self._neighbor_coord(pos, d) + if nxt is not None and nxt not in self.memory["visited"] and nxt not in self.memory["obstacles"]: + goal = nxt + break + if goal is not None: + action = self._bfs_next_step(pos, goal) + if action is not None: + self._log(pos, f"chose {action} (reason: BFS toward unvisited)") + else: + action = "NO-OP" + + # Rule 5: any legal move + if action is None: + for d in ("UP", "DOWN", "LEFT", "RIGHT"): + nxt = self._neighbor_coord(pos, d) + if nxt is not None and nxt not in self.memory["obstacles"]: + action = d + self._log(pos, f"chose {d} (reason: random legal move)") + break + + self.memory["last_action"] = action + return action + + +if __name__ == "__main__": + class DummyVacuumWorld: + """Minimal environment matching the lab's percept contract.""" + + def __init__(self): + self.grid = { + (0, 0): "start", + (0, 1): 0, + (0, 2): 0, + (0, 3): 0, + (1, 0): 0, + (1, 1): "X", + (1, 2): 0, + (1, 3): 0, + (2, 0): 0, + (2, 1): 0, + (2, 2): 0, + (2, 3): 0, + (3, 0): 0, + (3, 1): -1, + (3, 2): 0, + (3, 3): 10, + } + self.obstacles = {(1, 1)} + self.dirty = {(0, 1), (2, 2), (3, 1)} + self.pos = (0, 0) + + def percept(self): + pos = self.pos + cell = self.grid.get(pos, 0) + dirty = pos in self.dirty + neighbors = {} + for direction, delta in { + "UP": (-1, 0), + "DOWN": (1, 0), + "LEFT": (0, -1), + "RIGHT": (0, 1), + }.items(): + next_row = pos[0] + delta[0] + next_col = pos[1] + delta[1] + coord = (next_row, next_col) + if next_row < 0 or next_row >= 4 or next_col < 0 or next_col >= 4: + neighbors[direction] = None + elif coord in self.obstacles: + neighbors[direction] = None + else: + neighbors[direction] = self.grid.get(coord, 0) + return {"pos": pos, "cell": cell, "dirty": dirty, "neighbors": neighbors} + + env = DummyVacuumWorld() + agent = PersistentMemoryAgent(env) + print("Running demo for 30 steps...") + for _ in range(30): + percept = env.percept() + action = agent.program(percept) + print(f"Step {agent.memory['step_count']}: action={action}") + env.pos = (env.pos[0] + DIRECTIONS[action][0], env.pos[1] + DIRECTIONS[action][1]) + print("Memory dict:") + print(agent.memory) diff --git a/lab4_skill_run/logs/console_stderr.txt b/lab4_skill_run/logs/console_stderr.txt new file mode 100644 index 0000000..b56074d --- /dev/null +++ b/lab4_skill_run/logs/console_stderr.txt @@ -0,0 +1,4 @@ +[skills] discovered 2 skill(s): pr-review, vacuum-world-agent +[skills] prompt: 'Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass.' +[skills] similarity (threshold 0.20): vacuum-world-agent=0.590, pr-review=0.011 -> selected: vacuum-world-agent +[skills] executing skill 'vacuum-world-agent' (trigger=auto, score=0.590) from /Users/austinburt/Documents/Code/PythonEnv/Python/CS440/Project2-MiniCodingAgent-CS440/skills/vacuum-world-agent/SKILL.md; loaded files: spec.md, Test/test.py, vacuum-world-agent/reference.md diff --git a/lab4_skill_run/logs/console_stdout.txt b/lab4_skill_run/logs/console_stdout.txt new file mode 100644 index 0000000..d05d545 --- /dev/null +++ b/lab4_skill_run/logs/console_stdout.txt @@ -0,0 +1,17 @@ ++==============================================================================+ +| /\ /\\ | +| { `---' } | +| { O O } | +| ~~> V <~~ | +| \\ \|/ / | +| `-----'__ | +| MINI CODING AGENT | ++------------------------------------------------------------------------------+ +| | +| WORKSPACE /private/tmp/claude-501/-Users-...ff4a171a9de5/scratchpad/lab4_ws | +| MODEL qwen3.5:4b BRANCH - | +| APPROVAL auto SESSION 20261112-230213-8c7b96 | +| | ++==============================================================================+ + +Done. diff --git a/lab4_skill_run/logs/earlier_iterations/run1_stderr.txt b/lab4_skill_run/logs/earlier_iterations/run1_stderr.txt new file mode 100644 index 0000000..ddda09a --- /dev/null +++ b/lab4_skill_run/logs/earlier_iterations/run1_stderr.txt @@ -0,0 +1,4 @@ +[skills] discovered 2 skill(s): pr-review, vacuum-world-agent +[skills] prompt: 'Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass.' +[skills] similarity (threshold 0.20): vacuum-world-agent=0.590, pr-review=0.011 -> selected: vacuum-world-agent +[skills] executing skill 'vacuum-world-agent' (trigger=auto, score=0.590) from /Users/austinburt/Documents/Code/PythonEnv/Python/CS440/Project2-MiniCodingAgent-CS440/skills/vacuum-world-agent/SKILL.md diff --git a/lab4_skill_run/logs/earlier_iterations/run2_session.json b/lab4_skill_run/logs/earlier_iterations/run2_session.json new file mode 100644 index 0000000..d7fbf5e --- /dev/null +++ b/lab4_skill_run/logs/earlier_iterations/run2_session.json @@ -0,0 +1,120 @@ +{ + "id": "20261112-225907-a770bd", + "created_at": "2026-11-13T05:59:07.567050+00:00", + "workspace_root": "/private/tmp/claude-501/-Users-austinburt--claude-spares-spare-VqTafw/5181b628-40c6-4f68-b877-ff4a171a9de5/scratchpad/lab4_ws", + "history": [ + { + "role": "user", + "content": "Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass.", + "created_at": "2026-11-13T05:59:07.567293+00:00" + }, + { + "role": "skill", + "content": "activated skill 'vacuum-world-agent' (trigger=auto, score 0.590)", + "created_at": "2026-11-13T05:59:07.568697+00:00" + }, + { + "role": "tool", + "name": "write_file", + "args": {}, + "content": "error: invalid arguments for write_file: 'path'\nexample: def binary_search(nums, target):\n return -1\n", + "created_at": "2026-11-13T06:00:23.231816+00:00" + }, + { + "role": "tool", + "name": "write_file", + "args": { + "path": "agent_strategy1.py", + "content": "\"\"\"Persistent Memory Vacuum Agent for Lab 4.\"\"\"\n\nfrom collections import deque\n\nDIRECTIONS = {\n \"UP\": (-1, 0),\n \"DOWN\": (1, 0),\n \"LEFT\": (0, -1),\n \"RIGHT\": (0, 1),\n}\nGRID_SIZE = 4\n\n\nclass PersistentMemoryAgent:\n \"\"\"Vacuum world agent using persistent memory architecture.\"\"\"\n\n def __init__(self, env):\n \"\"\"Initialize the agent with environment reference and memory schema.\"\"\"\n self.env = env\n # --- 1. Initialize memory dict with explicit keys ---\n self.memory = {\n \"visited\": set(), # (r,c) cells the robot has stood on\n \"obstacles\": set(), # (r,c) cells known to be 'X' / OOB\n \"rewards\": {}, # (r,c) -> -1 or 10, observed values\n \"dirty_known\": set(), # (r,c) cells observed dirty and not yet sucked\n \"clean_known\": set(), # (r,c) cells observed clean after SUCK\n \"last_pos\": None, # (r,c) of previous step, for loop detection\n \"last_action\": None, # str, previous action\n \"step_count\": 0, # int, monotonic step counter\n \"history\": [], # list of (step, pos, action, note) log entries\n }\n\n def _log(self, pos, note):\n \"\"\"Append a history entry and print a trace line.\"\"\"\n step = self.memory[\"step_count\"]\n self.memory[\"history\"].append((step, pos, self.memory[\"last_action\"], note))\n print(f\"[step {step}] pos={pos} action={self.memory['last_action']} -> {note}\")\n\n def _neighbor_coord(self, pos, direction):\n \"\"\"Return the (r, c) one step in direction, or None if out of bounds.\"\"\"\n dr, dc = DIRECTIONS[direction]\n r, c = pos[0] + dr, pos[1] + dc\n if 0 <= r < GRID_SIZE and 0 <= c < GRID_SIZE:\n return (r, c)\n return None\n\n def _bfs_next_step(self, start, goal):\n \"\"\"Return the first action of a BFS path from start to goal, or None.\"\"\"\n if start == goal:\n return None\n queue = deque([(start, None)])\n seen = {start}\n while queue:\n pos, first = queue.popleft()\n for direction in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, direction)\n if nxt is None or nxt in seen or nxt in self.memory[\"obstacles\"]:\n continue\n step = first or direction\n if nxt == goal:\n return step\n seen.add(nxt)\n queue.append((nxt, step))\n return None\n\n def update_memory(self, percept):\n \"\"\"Merge percept into memory using explicit update rules.\"\"\"\n pos = percept[\"pos\"]\n\n # --- 1. Record current position as visited ---\n if pos not in self.memory[\"visited\"]:\n self.memory[\"visited\"].add(pos)\n self._log(pos, \"visited\")\n\n # --- 2. Record obstacles from neighbor senses ---\n for direction, cell in percept[\"neighbors\"].items():\n nbr = self._neighbor_coord(pos, direction)\n if cell is None and nbr is not None:\n if nbr not in self.memory[\"obstacles\"]:\n self.memory[\"obstacles\"].add(nbr)\n self._log(pos, f\"sensed obstacle at {nbr}\")\n\n # --- 3. Record rewards observed ---\n for direction, cell in percept[\"neighbors\"].items():\n nbr = self._neighbor_coord(pos, direction)\n if cell in (-1, 10) and nbr is not None:\n self.memory[\"rewards\"][nbr] = cell\n self._log(pos, f\"recorded reward {cell} at {nbr}\")\n\n # --- 4. Track dirty / clean ---\n if percept[\"dirty\"]:\n self.memory[\"dirty_known\"].add(pos)\n self._log(pos, \"observed dirty cell\")\n else:\n self.memory[\"dirty_known\"].discard(pos)\n self.memory[\"clean_known\"].add(pos)\n self._log(pos, \"observed clean cell\")\n\n # --- 5. Update step log AFTER using last_pos/last_action ---\n self.memory[\"history\"].append(\n (self.memory[\"step_count\"], pos,\n self.memory[\"last_action\"], \"memory updated\")\n )\n self.memory[\"last_pos\"] = pos\n self.memory[\"step_count\"] += 1\n\n def program(self, percept):\n \"\"\"Return the next action using memory + percept.\"\"\"\n pos = percept[\"pos\"]\n self.update_memory(percept)\n\n # Rule 1: current cell is dirty -> SUCK\n if percept[\"dirty\"]:\n action = \"SUCK\"\n self._log(pos, \"chose SUCK (reason: current cell dirty)\")\n # Rule 2: move toward a known dirty cell\n elif self.memory[\"dirty_known\"]:\n goal = None\n for d in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, d)\n if nxt and nxt in self.memory[\"dirty_known\"]:\n goal = nxt\n break\n if goal:\n action = self._bfs_next_step(pos, goal)\n self._log(pos, f\"chose {action} (reason: move toward nearest dirty at {goal})\")\n # Rule 3: explore an unvisited, open neighbor\n elif not self.memory[\"visited\"]:\n for d in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, d)\n if nxt and nxt not in self.memory[\"obstacles\"] and nxt not in self.memory[\"visited\"]:\n action = d\n self._log(pos, f\"chose {action} (reason: explore unvisited neighbor)\")\n break\n # Rule 4: BFS toward nearest unvisited known-safe cell\n elif not self.memory[\"visited\"]:\n goal = None\n for d in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, d)\n if nxt and nxt not in self.memory[\"obstacles\"] and nxt not in self.memory[\"visited\"]:\n goal = nxt\n break\n if goal:\n action = self._bfs_next_step(pos, goal)\n self._log(pos, f\"chose {action} (reason: BFS toward nearest unvisited at {goal})\")\n # Rule 5: any legal move\n else:\n for d in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, d)\n if nxt and nxt not in self.memory[\"obstacles\"]:\n action = d\n self._log(pos, f\"chose {action} (reason: random legal move)\")\n break\n\n self.memory[\"last_action\"] = action\n return action\n\n\nif __name__ == \"__main__\":\n print(\"Persistent Memory Agent demo\")\n env = DummyVacuumWorld()\n agent = PersistentMemoryAgent(env)\n for _ in range(30):\n percept = env.percept()\n action = agent.program(percept)\n print(f\"Step {agent.memory['step_count']}: pos={percept['pos']} action={action}\")\n" + }, + "content": "wrote agent_strategy1.py (6841 chars)", + "created_at": "2026-11-13T06:01:07.222388+00:00" + }, + { + "role": "tool", + "name": "run_shell", + "args": { + "command": "python -m pytest -q Test" + }, + "content": "exit_code: 5\nstdout:\nno tests ran in 0.00s\nstderr:\n(empty)", + "created_at": "2026-11-13T06:01:13.026504+00:00" + }, + { + "role": "tool", + "name": "run_shell", + "args": { + "command": "python -m pytest -q Test/" + }, + "content": "exit_code: 5\nstdout:\nno tests ran in 0.00s\nstderr:\n(empty)", + "created_at": "2026-11-13T06:01:18.971869+00:00" + }, + { + "role": "tool", + "name": "run_shell", + "args": { + "command": "python -m pytest Test/ -v" + }, + "content": "exit_code: 5\nstdout:\n============================= test session starts ==============================\nplatform darwin -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0 -- /Users/austinburt/miniforge3/bin/python\ncachedir: .pytest_cache\nrootdir: /private/tmp/claude-501/-Users-austinburt--claude-spares-spare-VqTafw/5181b628-40c6-4f68-b877-ff4a171a9de5/scratchpad/lab4_ws\nplugins: anyio-4.12.1, cov-7.1.0\ncollecting ... collected 0 items\n\n============================ no tests ran in 0.01s =============================\nstderr:\n(empty)", + "created_at": "2026-11-13T06:01:24.413305+00:00" + }, + { + "role": "tool", + "name": "list_files", + "args": { + "path": "." + }, + "content": "[D] Test\n[F] agent_strategy1.py\n[F] README.md\n[F] spec.md", + "created_at": "2026-11-13T06:01:29.312803+00:00" + }, + { + "role": "tool", + "name": "read_file", + "args": { + "path": "Test/test.py", + "start": 1, + "end": 50 + }, + "content": "# Test/test.py\n 1: import os\n 2: import sys\n 3: \n 4: import pytest\n 5: \n 6: ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), \"..\"))\n 7: if ROOT not in sys.path:\n 8: sys.path.insert(0, ROOT)\n 9: \n 10: from agent_strategy1 import PersistentMemoryAgent\n 11: \n 12: class DummyVacuumWorld:\n 13: \"\"\"Minimal environment matching the lab's percept contract.\"\"\"\n 14: \n 15: def __init__(self):\n 16: self.grid = {\n 17: (0, 0): \"start\",\n 18: (0, 1): 0,\n 19: (0, 2): 0,\n 20: (0, 3): 0,\n 21: (1, 0): 0,\n 22: (1, 1): \"X\",\n 23: (1, 2): 0,\n 24: (1, 3): 0,\n 25: (2, 0): 0,\n 26: (2, 1): 0,\n 27: (2, 2): 0,\n 28: (2, 3): 0,\n 29: (3, 0): 0,\n 30: (3, 1): -1,\n 31: (3, 2): 0,\n 32: (3, 3): 10,\n 33: }\n 34: self.obstacles = {(1, 1)}\n 35: self.dirty = {(0, 1), (2, 2), (3, 1)}\n 36: self.pos = (0, 0)\n 37: \n 38: def percept(self):\n 39: pos = self.pos\n 40: cell = self.grid.get(pos, 0)\n 41: dirty = pos in self.dirty\n 42: neighbors = {}\n 43: for direction, delta in {\n 44: \"UP\": (-1, 0),\n 45: \"DOWN\": (1, 0),\n 46: \"LEFT\": (0, -1),\n 47: \"RIGHT\": (0, 1),\n 48: }.items():\n 49: next_row = pos[0] + delta[0]\n 50: next_col = pos[1] + delta[1]", + "created_at": "2026-11-13T06:01:34.595577+00:00" + }, + { + "role": "assistant", + "content": "Runtime notice: model returned malformed tool JSON. Reply with a valid call or a non-empty answer. For multi-line files, prefer ....", + "created_at": "2026-11-13T06:01:40.342140+00:00" + }, + { + "role": "tool", + "name": "run_shell", + "args": { + "command": "python -m pytest Test/test.py -v" + }, + "content": "exit_code: 0\nstdout:\n============================= test session starts ==============================\nplatform darwin -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0 -- /Users/austinburt/miniforge3/bin/python\ncachedir: .pytest_cache\nrootdir: /private/tmp/claude-501/-Users-austinburt--claude-spares-spare-VqTafw/5181b628-40c6-4f68-b877-ff4a171a9de5/scratchpad/lab4_ws\nplugins: anyio-4.12.1, cov-7.1.0\ncollecting ... collected 5 items\n\nTest/test.py::test_memory_schema_is_initialized PASSED [ 20%]\nTest/test.py::test_update_memory_records_obstacles_and_dirty_state PASSED [ 40%]\nTest/test.py::test_agent_sucks_when_current_cell_is_dirty PASSED [ 60%]\nTest/test.py::test_agent_avoids_known_obstacles_and_moves_legally PASSED [ 80%]\nTest/test.py::test_bfs_next_step_for_reachable_target PASSED [100%]\n\n============================== 5 passed in 0.02s ===============================\nstderr:\n(empty)", + "created_at": "2026-11-13T06:01:41.788964+00:00" + }, + { + "role": "assistant", + "content": "All 5 tests passed successfully. The PersistentMemoryAgent has been implemented with persistent memory architecture, following the spec.md requirements for obstacle tracking, dirty cell detection, and BFS-based movement planning.", + "created_at": "2026-11-13T06:01:48.081633+00:00" + } + ], + "memory": { + "task": "Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass.", + "files": [ + "agent_strategy1.py", + "Test/test.py" + ], + "notes": [ + "run_shell: exit_code: 5 stdout: no tests ran in 0.00s stderr: (empty)", + "run_shell: exit_code: 5 stdout: ============================= test session starts ============================== platform darwin -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0 -- /Users/austinburt/miniforge3/bin/python cachedir: .py\n...[truncated 309 chars]", + "list_files: [D] Test [F] agent_strategy1.py [F] README.md [F] spec.md", + "read_file: # Test/test.py 1: import os 2: import sys 3: 4: import pytest 5: 6: ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), \"..\")) 7: if ROOT not in sys.path: 8: sys.path.insert(0, RO\n...[truncated 1328 chars]", + "run_shell: exit_code: 0 stdout: ============================= test session starts ============================== platform darwin -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0 -- /Users/austinburt/miniforge3/bin/python cachedir: .py\n...[truncated 711 chars]" + ] + }, + "active_skill": { + "name": "vacuum-world-agent", + "trigger": "auto", + "score": 0.5899, + "path": "/Users/austinburt/Documents/Code/PythonEnv/Python/CS440/Project2-MiniCodingAgent-CS440/skills/vacuum-world-agent/SKILL.md" + } +} \ No newline at end of file diff --git a/lab4_skill_run/logs/session.json b/lab4_skill_run/logs/session.json new file mode 100644 index 0000000..4ddb2d2 --- /dev/null +++ b/lab4_skill_run/logs/session.json @@ -0,0 +1,103 @@ +{ + "id": "20261112-230213-8c7b96", + "created_at": "2026-11-13T06:02:13.632082+00:00", + "workspace_root": "/private/tmp/claude-501/-Users-austinburt--claude-spares-spare-VqTafw/5181b628-40c6-4f68-b877-ff4a171a9de5/scratchpad/lab4_ws", + "history": [ + { + "role": "user", + "content": "Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass.", + "created_at": "2026-11-13T06:02:13.632320+00:00" + }, + { + "role": "skill", + "content": "activated skill 'vacuum-world-agent' (trigger=auto, score 0.590)", + "created_at": "2026-11-13T06:02:13.635063+00:00" + }, + { + "role": "tool", + "name": "write_file", + "args": {}, + "content": "error: invalid arguments for write_file: 'path'\nexample: def binary_search(nums, target):\n return -1\n", + "created_at": "2026-11-13T06:03:20.773026+00:00" + }, + { + "role": "tool", + "name": "write_file", + "args": {}, + "content": "error: invalid arguments for write_file: 'path'\nexample: def binary_search(nums, target):\n return -1\n", + "created_at": "2026-11-13T06:04:25.733153+00:00" + }, + { + "role": "tool", + "name": "list_files", + "args": { + "path": "." + }, + "content": "[D] Test\n[F] README.md\n[F] spec.md", + "created_at": "2026-11-13T06:04:29.110497+00:00" + }, + { + "role": "tool", + "name": "write_file", + "args": {}, + "content": "error: invalid arguments for write_file: 'path'\nexample: def binary_search(nums, target):\n return -1\n", + "created_at": "2026-11-13T06:05:47.710537+00:00" + }, + { + "role": "tool", + "name": "write_file", + "args": {}, + "content": "error: invalid arguments for write_file: 'path'\nexample: def binary_search(nums, target):\n return -1\n", + "created_at": "2026-11-13T06:06:57.184493+00:00" + }, + { + "role": "tool", + "name": "write_file", + "args": {}, + "content": "error: invalid arguments for write_file: 'path'\nexample: def binary_search(nums, target):\n return -1\n", + "created_at": "2026-11-13T06:08:27.371888+00:00" + }, + { + "role": "tool", + "name": "write_file", + "args": { + "path": "agent_strategy1.py", + "content": "\"\"\"Persistent Memory Vacuum Agent for Lab 4.\"\"\"\n\nfrom collections import deque\n\nDIRECTIONS = {\n \"UP\": (-1, 0),\n \"DOWN\": (1, 0),\n \"LEFT\": (0, -1),\n \"RIGHT\": (0, 1),\n}\nGRID_SIZE = 4\n\n\nclass PersistentMemoryAgent:\n \"\"\"Vacuum world agent using persistent memory architecture.\"\"\"\n\n def __init__(self, env):\n \"\"\"Initialize the agent with environment reference and memory schema.\"\"\"\n self.env = env\n self.memory = {\n \"visited\": set(), # (r,c) cells the robot has stood on\n \"obstacles\": set(), # (r,c) cells known to be 'X' / OOB\n \"rewards\": {}, # (r,c) -> -1 or 10, observed values\n \"dirty_known\": set(), # (r,c) cells observed dirty and not yet sucked\n \"clean_known\": set(), # (r,c) cells observed clean after SUCK\n \"last_pos\": None, # (r,c) of previous step, for loop detection\n \"last_action\": None, # str, previous action\n \"step_count\": 0, # int, monotonic step counter\n \"history\": [], # list of (step, pos, action, note) log entries\n }\n\n def _log(self, pos, note):\n \"\"\"Append a history entry and print a trace line.\"\"\"\n step = self.memory[\"step_count\"]\n self.memory[\"history\"].append((step, pos, self.memory[\"last_action\"], note))\n print(f\"[step {step}] pos={pos} action={self.memory['last_action']} -> {note}\")\n\n def _neighbor_coord(self, pos, direction):\n \"\"\"Return the (r, c) one step in direction, or None if out of bounds.\"\"\"\n dr, dc = DIRECTIONS[direction]\n r, c = pos[0] + dr, pos[1] + dc\n if 0 <= r < GRID_SIZE and 0 <= c < GRID_SIZE:\n return (r, c)\n return None\n\n def _bfs_next_step(self, start, goal):\n \"\"\"Return the first action of a BFS path from start to goal, or None.\"\"\"\n if start == goal:\n return None\n queue = deque([(start, None)])\n seen = {start}\n while queue:\n pos, first = queue.popleft()\n for direction in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, direction)\n if nxt is None or nxt in seen or nxt in self.memory[\"obstacles\"]:\n continue\n step = first or direction\n if nxt == goal:\n return step\n seen.add(nxt)\n queue.append((nxt, step))\n return None\n\n def update_memory(self, percept):\n \"\"\"Merge percept into memory with explicit update blocks.\"\"\"\n pos = percept[\"pos\"]\n\n # --- 1. Record current position as visited ---\n if pos not in self.memory[\"visited\"]:\n self.memory[\"visited\"].add(pos)\n self._log(pos, \"visited\")\n\n # --- 2. Record obstacles from neighbor senses ---\n for direction, cell in percept[\"neighbors\"].items():\n nbr = self._neighbor_coord(pos, direction)\n if cell is None and nbr is not None:\n if nbr not in self.memory[\"obstacles\"]:\n self.memory[\"obstacles\"].add(nbr)\n self._log(pos, f\"sensed obstacle at {nbr}\")\n\n # --- 3. Record rewards observed ---\n for direction, cell in percept[\"neighbors\"].items():\n nbr = self._neighbor_coord(pos, direction)\n if cell in (-1, 10) and nbr is not None:\n self.memory[\"rewards\"][nbr] = cell\n self._log(pos, f\"recorded reward {cell} at {nbr}\")\n\n # --- 4. Track dirty / clean ---\n if percept[\"dirty\"]:\n self.memory[\"dirty_known\"].add(pos)\n self._log(pos, \"observed dirty cell\")\n else:\n self.memory[\"dirty_known\"].discard(pos)\n self.memory[\"clean_known\"].add(pos)\n self._log(pos, \"observed clean cell\")\n\n # --- 5. Update step log AFTER using last_pos/last_action ---\n self.memory[\"history\"].append(\n (self.memory[\"step_count\"], pos,\n self.memory[\"last_action\"], \"memory updated\")\n )\n self.memory[\"last_pos\"] = pos\n self.memory[\"step_count\"] += 1\n\n def program(self, percept):\n \"\"\"Return the next action using memory + percept.\"\"\"\n pos = percept[\"pos\"]\n self.update_memory(percept)\n\n # Rule 1: current cell is dirty -> SUCK\n if percept[\"dirty\"]:\n action = \"SUCK\"\n self._log(pos, \"chose SUCK (reason: current cell dirty)\")\n # Rule 2: move toward a known dirty cell\n elif self.memory[\"dirty_known\"]:\n goal = None\n for d in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, d)\n if nxt in self.memory[\"dirty_known\"] and goal is None:\n goal = nxt\n break\n if goal is not None:\n action = self._bfs_next_step(pos, goal)\n if action is not None:\n self._log(pos, f\"chose {action} (reason: nearest dirty at {goal})\")\n else:\n action = \"NO-OP\"\n # Rule 3: explore an unvisited, open neighbor\n elif self.memory[\"visited\"]:\n for d in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, d)\n if nxt is not None and nxt not in self.memory[\"visited\"] and nxt not in self.memory[\"obstacles\"]:\n action = d\n self._log(pos, f\"chose {d} (reason: unvisited neighbor)\")\n break\n # Rule 4: BFS toward nearest unvisited known-safe cell\n else:\n goal = None\n for d in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, d)\n if nxt is not None and nxt not in self.memory[\"visited\"] and nxt not in self.memory[\"obstacles\"]:\n goal = nxt\n break\n if goal is not None:\n action = self._bfs_next_step(pos, goal)\n if action is not None:\n self._log(pos, f\"chose {action} (reason: BFS toward unvisited)\")\n else:\n action = \"NO-OP\"\n\n # Rule 5: any legal move\n if action is None:\n for d in (\"UP\", \"DOWN\", \"LEFT\", \"RIGHT\"):\n nxt = self._neighbor_coord(pos, d)\n if nxt is not None and nxt not in self.memory[\"obstacles\"]:\n action = d\n self._log(pos, f\"chose {d} (reason: random legal move)\")\n break\n\n self.memory[\"last_action\"] = action\n return action\n\n\nif __name__ == \"__main__\":\n class DummyVacuumWorld:\n \"\"\"Minimal environment matching the lab's percept contract.\"\"\"\n\n def __init__(self):\n self.grid = {\n (0, 0): \"start\",\n (0, 1): 0,\n (0, 2): 0,\n (0, 3): 0,\n (1, 0): 0,\n (1, 1): \"X\",\n (1, 2): 0,\n (1, 3): 0,\n (2, 0): 0,\n (2, 1): 0,\n (2, 2): 0,\n (2, 3): 0,\n (3, 0): 0,\n (3, 1): -1,\n (3, 2): 0,\n (3, 3): 10,\n }\n self.obstacles = {(1, 1)}\n self.dirty = {(0, 1), (2, 2), (3, 1)}\n self.pos = (0, 0)\n\n def percept(self):\n pos = self.pos\n cell = self.grid.get(pos, 0)\n dirty = pos in self.dirty\n neighbors = {}\n for direction, delta in {\n \"UP\": (-1, 0),\n \"DOWN\": (1, 0),\n \"LEFT\": (0, -1),\n \"RIGHT\": (0, 1),\n }.items():\n next_row = pos[0] + delta[0]\n next_col = pos[1] + delta[1]\n coord = (next_row, next_col)\n if next_row < 0 or next_row >= 4 or next_col < 0 or next_col >= 4:\n neighbors[direction] = None\n elif coord in self.obstacles:\n neighbors[direction] = None\n else:\n neighbors[direction] = self.grid.get(coord, 0)\n return {\"pos\": pos, \"cell\": cell, \"dirty\": dirty, \"neighbors\": neighbors}\n\n env = DummyVacuumWorld()\n agent = PersistentMemoryAgent(env)\n print(\"Running demo for 30 steps...\")\n for _ in range(30):\n percept = env.percept()\n action = agent.program(percept)\n print(f\"Step {agent.memory['step_count']}: action={action}\")\n env.pos = (env.pos[0] + DIRECTIONS[action][0], env.pos[1] + DIRECTIONS[action][1])\n print(\"Memory dict:\")\n print(agent.memory)\n" + }, + "content": "wrote agent_strategy1.py (8657 chars)", + "created_at": "2026-11-13T06:09:26.275261+00:00" + }, + { + "role": "tool", + "name": "run_shell", + "args": { + "command": "python -m pytest -q Test/*.py" + }, + "content": "exit_code: 0\nstdout:\n..... [100%]\n5 passed in 0.01s\nstderr:\n(empty)", + "created_at": "2026-11-13T06:09:32.439306+00:00" + }, + { + "role": "assistant", + "content": "Done.", + "created_at": "2026-11-13T06:09:37.950339+00:00" + } + ], + "memory": { + "task": "Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass.", + "files": [ + "agent_strategy1.py" + ], + "notes": [ + "list_files: [D] Test [F] README.md [F] spec.md", + "write_file: error: invalid arguments for write_file: 'path' example: def binary_search(nums, target): return -1 ", + "write_file: wrote agent_strategy1.py (8657 chars)", + "run_shell: exit_code: 0 stdout: ..... [100%] 5 passed in 0.01s stderr: (empty)" + ] + }, + "active_skill": { + "name": "vacuum-world-agent", + "trigger": "auto", + "score": 0.5899, + "path": "/Users/austinburt/Documents/Code/PythonEnv/Python/CS440/Project2-MiniCodingAgent-CS440/skills/vacuum-world-agent/SKILL.md" + } +} \ No newline at end of file diff --git a/lab4_skill_run/logs/skills.jsonl b/lab4_skill_run/logs/skills.jsonl new file mode 100644 index 0000000..9748b8e --- /dev/null +++ b/lab4_skill_run/logs/skills.jsonl @@ -0,0 +1,4 @@ +{"ts": "2026-11-13T06:02:13.631622+00:00", "session_id": null, "event": "discover", "roots": ["/private/tmp/claude-501/-Users-austinburt--claude-spares-spare-VqTafw/5181b628-40c6-4f68-b877-ff4a171a9de5/scratchpad/lab4_ws/skills", "/private/tmp/claude-501/-Users-austinburt--claude-spares-spare-VqTafw/5181b628-40c6-4f68-b877-ff4a171a9de5/scratchpad/lab4_ws/skill", "/Users/austinburt/Documents/Code/PythonEnv/Python/CS440/Project2-MiniCodingAgent-CS440/skills", "/Users/austinburt/Documents/Code/PythonEnv/Python/CS440/Project2-MiniCodingAgent-CS440/skill"], "skills": ["pr-review", "vacuum-world-agent"], "skipped": []} +{"ts": "2026-11-13T06:02:13.632435+00:00", "session_id": "20261112-230213-8c7b96", "event": "prompt", "prompt": "Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass."} +{"ts": "2026-11-13T06:02:13.632745+00:00", "session_id": "20261112-230213-8c7b96", "event": "similarity_check", "prompt": "Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass.", "threshold": 0.2, "scores": [{"skill": "vacuum-world-agent", "score": 0.5899, "cosine": 0.3165, "trigger_hits": ["spec.md"]}, {"skill": "pr-review", "score": 0.0113, "cosine": 0.0189, "trigger_hits": []}], "selected": "vacuum-world-agent"} +{"ts": "2026-11-13T06:02:13.634974+00:00", "session_id": "20261112-230213-8c7b96", "event": "skill_executed", "skill": "vacuum-world-agent", "trigger": "auto", "score": 0.5899, "path": "/Users/austinburt/Documents/Code/PythonEnv/Python/CS440/Project2-MiniCodingAgent-CS440/skills/vacuum-world-agent/SKILL.md", "loaded_files": ["spec.md", "Test/test.py", "vacuum-world-agent/reference.md"]} diff --git a/lab4_skill_run/spec.md b/lab4_skill_run/spec.md new file mode 100644 index 0000000..65ba865 --- /dev/null +++ b/lab4_skill_run/spec.md @@ -0,0 +1,189 @@ +# spec.md — Strategy 1: Persistent Memory Vacuum Agent + +## Goal + +Implement a Python agent for the AIMA vacuum world that uses a +persistent-memory architecture. Today: scaffolding only, NO Q-learning. + +## Environment Contract (implemented in vacuum_env.py, do not modify here) + +- Grid: 4x4. Coordinates are (row, col), 0-indexed. +- Cells: 'start' (robot start, at (0,0)), 'X' (obstacle), + -1 (penalty), 10 (reward), 0 (plain). +- The robot occupies one cell at a time. +- Actions: 'UP', 'DOWN', 'LEFT', 'RIGHT', 'SUCK', 'NO-OP'. +- Percepts available to the agent each step: + { + "pos": (row, col), + "cell": , # 0, -1, 10, or 'start' + "dirty": bool, # True if cell needs SUCK + "neighbors": { # what the agent can sense adjacently + "UP": , # None = out of bounds / obstacle + "DOWN": ..., "LEFT": ..., "RIGHT": ... + } + } + +## Agent Contract (agent_strategy1.py) + +Define class `PersistentMemoryAgent` with: + +### State + +- `self.memory: dict` — persistent memory. At minimum: + - `visited: set[(r,c)]` + - `obstacles: set[(r,c)]` + - `rewards: dict[(r,c) -> value]` + - `dirty_known: set[(r,c)]` +- `self.last_action: str | None` +- `self.last_pos: (r,c) | None` + +### Methods + +- `__init__(self, env)` — store env reference, init memory. +- `update_memory(self, percept)` — merge percept into memory. + Rules: + - mark `pos` visited + - if `neighbors[d]` is None → add neighbor coord to obstacles + - if `neighbors[d]` in (-1, 10) → record in rewards + - if `dirty` is True → add pos to dirty_known +- `program(self, percept) -> str` — return next action. + Decision priority (in order): + 1. If `percept["dirty"]` → return 'SUCK' + 2. If a cell in `dirty_known` is reachable → move one step toward it + 3. Otherwise → explore: pick an unvisited, non-obstacle neighbor + 4. If no unvisited neighbor → move toward nearest unvisited cell + via BFS over known-safe cells + 5. If all else fails → return a valid random move +- `_bfs_next_step(self, start, goal) -> str` — helper: BFS over + cells that are NOT in self.memory["obstacles"]; return first action. + +## Constraints + +- Pure Python 3.9+, standard library only (no numpy). +- The agent MUST NOT look at the full grid directly — only via percepts. +- Memory must actually be used: the agent must not re-enter a cell it + already knows is an obstacle, and must not re-SUCK a known-clean cell. +- Keep functions short; add docstrings to each public method. + +## Deliverable for this file + +Only `agent_strategy1.py`. Do not touch `vacuum_env.py`, +`agent_strategy4.py`, or `main.py` in this step. + +## Test Expectations + +Running `main.py` with `PersistentMemoryAgent` for 30 steps should: + +- end with at least 3 cells cleaned (dirty_known drained to empty or + the robot stuck without new dirt), +- never crash on out-of-bounds moves, +- print the memory dict at the end. + +## Persistent Memory — Verbosity & Explicitness Requirements + +The persistent memory is the core of Strategy 1. It MUST be verbose and +explicit. Do NOT compress it into a single dict literal or a one-liner. + +### Required memory schema (explicit, named fields) + +`self.memory` MUST be a dict with ALL of the following keys, each +initialized explicitly in `__init__` with a comment explaining its role: + + self.memory = { + "visited": set(), # (r,c) cells the robot has stood on + "obstacles": set(), # (r,c) cells known to be 'X' / OOB + "rewards": {}, # (r,c) -> -1 or 10, observed values + "dirty_known": set(), # (r,c) cells observed dirty and not yet sucked + "clean_known": set(), # (r,c) cells observed clean after SUCK + "last_pos": None, # (r,c) of previous step, for loop detection + "last_action": None, # str, previous action + "step_count": 0, # int, monotonic step counter + "history": [], # list of (step, pos, action, note) log entries + } + +### Explicit-update rules + +`update_memory(percept)` MUST: + +1. Be written as a sequence of small, named, commented steps — one + logical update per block. No chained one-liners. +2. Append an entry to `self.memory["history"]` on EVERY step with: + `(step_count, pos, last_action, )`. + Example note: `"sensed obstacle at (1,1)"` or `"SUCK on dirty (0,0)"`. +3. Never overwrite `last_pos` / `last_action` before using them in the + history log (order matters). +4. Add a `print()` (or a `log()` helper) that emits one line per update + so the run is traceable in the terminal, e.g.: + `[step 3] pos=(0,1) action=RIGHT → marked visited, sensed dirty=False` + +### Explicit decision rules + +`program(percept)` MUST: + +1. Return via clearly separated `if/elif` branches, each with a comment + naming the rule (e.g. `# Rule 2: move toward nearest known-dirty cell`). +2. When choosing an action, log WHY it was chosen: + `[step 4] chose UP (reason: nearest dirty at (0,2), BFS next step)` +3. Never inline a lambda or a comprehension that hides a decision. + +### Forbidden patterns + +- `self.memory = {}` (empty dict, no schema) +- `self.memory["visited"].add(pos)` with no comment / no log entry +- Any `;`-joined or chained-assignment memory update +- Silent `try/except` that swallows a memory update +- Reassigning `self.memory = {...}` mid-run (mutate in place only) + +### Required reference example (this is the style to match) + +Include this snippet in the generated file as a style anchor: + + def update_memory(self, percept): + pos = percept["pos"] + + # --- 1. Record current position as visited --- + if pos not in self.memory["visited"]: + self.memory["visited"].add(pos) + self._log(pos, "visited") + + # --- 2. Record obstacles from neighbor senses --- + for direction, cell in percept["neighbors"].items(): + nbr = self._neighbor_coord(pos, direction) + if cell is None and nbr is not None: + if nbr not in self.memory["obstacles"]: + self.memory["obstacles"].add(nbr) + self._log(pos, f"sensed obstacle at {nbr}") + + # --- 3. Record rewards observed --- + for direction, cell in percept["neighbors"].items(): + nbr = self._neighbor_coord(pos, direction) + if cell in (-1, 10) and nbr is not None: + self.memory["rewards"][nbr] = cell + self._log(pos, f"recorded reward {cell} at {nbr}") + + # --- 4. Track dirty / clean --- + if percept["dirty"]: + self.memory["dirty_known"].add(pos) + self._log(pos, "observed dirty cell") + else: + self.memory["dirty_known"].discard(pos) + self.memory["clean_known"].add(pos) + self._log(pos, "observed clean cell") + + # --- 5. Update step log AFTER using last_pos/last_action --- + self.memory["history"].append( + (self.memory["step_count"], pos, + self.memory["last_action"], "memory updated") + ) + self.memory["last_pos"] = pos + self.memory["step_count"] += 1 + +### Required helper methods + +Define these explicitly (do not inline them): + +- `_log(self, pos, note)` — appends to history and prints a trace line. +- `_neighbor_coord(self, pos, direction)` — returns the (r,c) one step + in `direction`, or None if out of bounds. +- `_bfs_next_step(self, start, goal)` — returns the first action toward + `goal`, using BFS over cells NOT in `self.memory["obstacles"]`. diff --git a/mini_coding_agent.py b/mini_coding_agent.py index 1bf2403..e3cdce6 100644 --- a/mini_coding_agent.py +++ b/mini_coding_agent.py @@ -1,5 +1,6 @@ import argparse import json +import math import re import shutil import subprocess @@ -12,7 +13,7 @@ DOC_NAMES = ("AGENTS.md", "README.md", "pyproject.toml", "package.json") -HELP_TEXT = "/help, /memory, /session, /reset, /exit" +HELP_TEXT = "/help, /memory, /session, /skills, /skill, /reset, /exit" WELCOME_ART = ( "/\\ /\\\\", "{ `---' }", @@ -27,6 +28,8 @@ "/help Show this help message.", "/memory Show the agent's distilled working memory.", "/session Show the path to the saved session file.", + "/skills List the agent skills that were discovered.", + "/skill Run a request with a specific skill.", "/reset Clear the current session history and memory.", "/exit Exit the agent.", ] @@ -44,6 +47,7 @@ # 4) Context Reduction And Output Management -> clip, history_text # 5) Transcripts, Memory, And Resumption -> SessionStore, record, note_tool, ask, reset # 6) Delegation And Bounded Subagents -> tool_delegate +# 7) Agent Skills -> Skill, SkillRegistry, SkillLogger, select_skill, activate_skill, tool_use_skill def now(): @@ -222,6 +226,247 @@ def complete(self, prompt, max_new_tokens): return data.get("response", "") +############################## +#### 7) Agent Skills ######### +############################## +# A skill is a folder with a SKILL.md file (front matter + instructions). +# The registry discovers skills, scores them against a prompt with a +# stdlib-only TF-IDF + trigger-phrase similarity check, and the agent injects +# the selected skill's instructions into the prompt. Every step is logged. +SKILL_FILE = "SKILL.md" +SKILL_DIR_NAMES = ("skills", "skill") +SKILL_THRESHOLD = 0.20 +SKILL_BODY_LIMIT = 6000 +SKILL_FILE_LIMIT = 9000 +SKILL_CONTEXT_LIMIT = 20000 +STOP_WORDS = frozenset( + """a an and are as at be by can do does for from has have how i in into is it its me my of on or + our please should so that the their them then there these this to us use using want we what when + which will with would you your""" + .split() +) + + +class Skill: + def __init__(self, name, description, triggers, body, path, context=(), resources=()): + self.name = name + self.description = description + self.triggers = triggers + self.body = body + self.path = Path(path) + self.context = list(context) # workspace files / globs to preload + self.resources = list(resources) # files inside the skill folder to preload + + @property + def directory(self): + return self.path.parent + + def document(self): + # Text used for the similarity check; triggers count twice. + triggers = " ".join(self.triggers) + return " ".join([self.name.replace("-", " "), self.description, triggers, triggers]) + + +def parse_skill_file(path): + path = Path(path) + text = path.read_text(encoding="utf-8", errors="replace") + meta = {} + body = text + lines = text.splitlines() + if lines and lines[0].strip() == "---": + for index in range(1, len(lines)): + if lines[index].strip() == "---": + for line in lines[1:index]: + if ":" in line: + key, value = line.split(":", 1) + meta[key.strip().lower()] = value.strip().strip("\"'") + body = "\n".join(lines[index + 1:]) + break + name = meta.get("name") or path.parent.name + description = meta.get("description", "").strip() + if not description: + raise ValueError("missing description in front matter") + def items(key): + return [item.strip() for item in meta.get(key, "").split(",") if item.strip()] + + return Skill( + name=name, + description=description, + triggers=items("triggers"), + body=body.strip(), + path=path, + context=items("context"), + resources=items("resources"), + ) + + +def tokenize(text): + tokens = [] + for word in re.findall(r"[a-z0-9]+", str(text).lower()): + if len(word) < 2 or word in STOP_WORDS: + continue + for suffix in ("ing", "ed", "es", "s"): + if len(word) > len(suffix) + 2 and word.endswith(suffix): + word = word[: -len(suffix)] + break + tokens.append(word) + return tokens + + +class SkillLogger: + def __init__(self, path=None, echo=True, stream=None): + self.path = Path(path) if path else None + self.echo = echo + self.stream = stream + self.session_id = None + self.events = [] + if self.path: + self.path.parent.mkdir(parents=True, exist_ok=True) + + def log(self, event, **fields): + entry = {"ts": now(), "session_id": self.session_id, "event": event, **fields} + self.events.append(entry) + if self.path: + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, default=str) + "\n") + if self.echo: + print(f"[skills] {self.describe(entry)}", file=self.stream or sys.stderr) + return entry + + @staticmethod + def describe(entry): + event = entry["event"] + if event == "prompt": + return f"prompt: {middle(entry['prompt'], 120)!r}" + if event == "similarity_check": + scores = ", ".join(f"{item['skill']}={item['score']:.3f}" for item in entry["scores"]) or "no skills" + return f"similarity (threshold {entry['threshold']:.2f}): {scores} -> selected: {entry['selected'] or 'none'}" + if event == "skill_executed": + score = "-" if entry.get("score") is None else f"{entry['score']:.3f}" + files = ", ".join(entry.get("loaded_files") or []) or "-" + return ( + f"executing skill '{entry['skill']}' (trigger={entry['trigger']}, score={score}) " + f"from {entry['path']}; loaded files: {files}" + ) + if event == "discover": + return f"discovered {len(entry['skills'])} skill(s): {', '.join(entry['skills']) or '-'}" + if event == "skill_not_found": + return f"skill '{entry['requested']}' not found; available: {', '.join(entry['available']) or '-'}" + return json.dumps(entry, default=str) + + +class SkillRegistry: + def __init__(self, skills=None, threshold=SKILL_THRESHOLD, logger=None): + self.skills = {} + for skill in skills or []: + self.skills.setdefault(skill.name, skill) + self.threshold = threshold + self.logger = logger or SkillLogger(echo=False) + + @staticmethod + def default_roots(workspace, extra_dirs=()): + roots = [Path(item) for item in extra_dirs] + for base in (Path(workspace.repo_root), Path(workspace.cwd), Path(__file__).resolve().parent): + roots.extend(base / name for name in SKILL_DIR_NAMES) + unique = [] + for root in roots: + resolved = root.resolve() + if resolved not in unique: + unique.append(resolved) + return unique + + @classmethod + def discover(cls, roots, threshold=SKILL_THRESHOLD, logger=None): + logger = logger or SkillLogger(echo=False) + skills, skipped = [], [] + seen = set() + for root in roots: + root = Path(root) + if not root.is_dir(): + continue + for folder in sorted(item for item in root.iterdir() if item.is_dir()): + skill_file = folder / SKILL_FILE + if not skill_file.is_file(): + continue + try: + skill = parse_skill_file(skill_file) + except Exception as exc: + skipped.append({"path": str(skill_file), "reason": str(exc)}) + continue + if skill.name in seen: + continue + seen.add(skill.name) + skills.append(skill) + registry = cls(skills, threshold=threshold, logger=logger) + logger.log( + "discover", + roots=[str(root) for root in roots], + skills=[skill.name for skill in skills], + skipped=skipped, + ) + return registry + + def get(self, name): + return self.skills.get(str(name).strip()) + + def catalogue(self): + return "\n".join(f"- {skill.name}: {skill.description}" for skill in self.skills.values()) + + def scores(self, prompt): + skills = list(self.skills.values()) + if not skills: + return [] + docs = [tokenize(skill.document()) for skill in skills] + count = len(docs) + df = {} + for doc in docs: + for token in set(doc): + df[token] = df.get(token, 0) + 1 + + def vector(tokens): + vec = {} + for token in tokens: + vec[token] = vec.get(token, 0) + 1 + return { + token: tf * (math.log((count + 1) / (df.get(token, 0) + 1)) + 1) + for token, tf in vec.items() + } + + query = vector(tokenize(prompt)) + query_norm = math.sqrt(sum(value * value for value in query.values())) + lowered = str(prompt).lower() + results = [] + for skill, doc in zip(skills, docs): + vec = vector(doc) + norm = math.sqrt(sum(value * value for value in vec.values())) + dot = sum(value * vec.get(token, 0.0) for token, value in query.items()) + cosine = dot / (query_norm * norm) if query_norm and norm else 0.0 + hits = [phrase for phrase in skill.triggers if phrase.lower() in lowered] + score = 0.6 * cosine + 0.4 * (1.0 if hits else 0.0) + results.append({ + "skill": skill.name, + "score": round(score, 4), + "cosine": round(cosine, 4), + "trigger_hits": hits, + }) + results.sort(key=lambda item: item["score"], reverse=True) + return results + + def match(self, prompt): + results = self.scores(prompt) + best = results[0] if results and results[0]["score"] >= self.threshold else None + self.logger.log( + "similarity_check", + prompt=prompt, + threshold=self.threshold, + scores=results, + selected=best["skill"] if best else None, + ) + if not best: + return None, None + return self.skills[best["skill"]], best["score"] + + class MiniAgent: def __init__( self, @@ -235,8 +480,10 @@ def __init__( depth=0, max_depth=1, read_only=False, + skills=None, ): self.model_client = model_client + self.skills = skills self.workspace = workspace self.root = Path(workspace.repo_root) self.session_store = session_store @@ -253,6 +500,9 @@ def __init__( "history": [], "memory": {"task": "", "files": [], "notes": []}, } + self.session.setdefault("active_skill", None) + if self.skills is not None: + self.skills.logger.session_id = self.session["id"] self.tools = self.build_tools() self.prefix = self.build_prefix() self.session_path = self.session_store.save(self.session) @@ -318,6 +568,13 @@ def build_tools(self): "run": self.tool_patch_file, }, } + if self.skills and self.skills.skills: + tools["use_skill"] = { + "schema": {"name": "str"}, + "risky": False, + "description": "Load an agent skill's step-by-step instructions when the task needs it.", + "run": self.tool_use_skill, + } if self.depth < self.max_depth: tools["delegate"] = { "schema": {"task": "str", "max_steps": "int=3"}, @@ -365,13 +622,19 @@ def build_prefix(self): "- Do not repeat the same tool call with the same arguments if it did not help. Choose a different tool or return a final answer.", "- Required tool arguments must not be empty. Do not call read_file, write_file, patch_file, run_shell, or delegate with args={}.", ]) - return "\n\n".join([ + sections = [ "You are Mini-Coding-Agent, a small local coding agent running through Ollama.", "Rules:\n" + rules, "Tools:\n" + tool_text, "Valid response examples:\n" + examples, - self.workspace.text(), - ]) + ] + if self.skills and self.skills.skills: + sections.append( + "Agent skills (call use_skill with the name when a task needs one; " + "an active skill's instructions take priority):\n" + self.skills.catalogue() + ) + sections.append(self.workspace.text()) + return "\n\n".join(sections) def memory_text(self): memory = self.session["memory"] @@ -420,12 +683,123 @@ def history_text(self): #### 2) Prompt Shape And Cache Reuse (Continued) ####### ######################################################## def prompt(self, user_message): - return "\n\n".join([ - self.prefix, - self.memory_text(), - "Transcript:\n" + self.history_text(), - "Current user request:\n" + user_message, - ]) + sections = [self.prefix, self.memory_text()] + skill_text = self.skill_text() + if skill_text: + sections.append(skill_text) + sections.append("Transcript:\n" + self.history_text()) + sections.append("Current user request:\n" + user_message) + return "\n\n".join(sections) + + ############################## + #### 7) Agent Skills ######### + ############################## + def skill_text(self): + active = self.session.get("active_skill") + if not active or not self.skills: + return "" + skill = self.skills.get(active["name"]) + if skill is None: + return "" + directory = skill.directory + try: + directory = directory.relative_to(self.root) + except ValueError: + pass + lines = [ + f"Active skill: {skill.name}", + f"- skill directory: {directory}", + "- Follow these instructions step by step. They override general habits.", + "Skill instructions:", + clip(skill.body, SKILL_BODY_LIMIT), + ] + files = self.skill_files(skill) + if files: + lines.append("Skill files (already loaded in full; do not re-read them with read_file):") + for label, text in files: + lines.append(f"\n{text}\n") + return "\n".join(lines) + + def skill_files(self, skill): + # Loads the skill's declared workspace context files and bundled resources. + loaded, budget = [], SKILL_CONTEXT_LIMIT + candidates = [] + for pattern in skill.context: + matches = sorted(self.root.glob(pattern)) if any(ch in pattern for ch in "*?[") else [self.root / pattern] + for path in matches: + try: + path = self.path(path) + except ValueError: + continue + if path.is_file() and not any(part in IGNORED_PATH_NAMES for part in path.relative_to(self.root).parts): + candidates.append((str(path.relative_to(self.root)), path)) + for name in skill.resources: + path = (skill.directory / name).resolve() + if path.is_file() and skill.directory.resolve() in path.parents: + candidates.append((f"{skill.name}/{name}", path)) + for label, path in candidates: + if budget <= 0: + break + text = clip(path.read_text(encoding="utf-8", errors="replace"), min(SKILL_FILE_LIMIT, budget)) + budget -= len(text) + loaded.append((label, text)) + return loaded + + def activate_skill(self, skill, trigger, score=None): + self.session["active_skill"] = { + "name": skill.name, + "trigger": trigger, + "score": score, + "path": str(skill.path), + } + self.skills.logger.log( + "skill_executed", + skill=skill.name, + trigger=trigger, + score=score, + path=str(skill.path), + loaded_files=[label for label, _ in self.skill_files(skill)], + ) + detail = f"score {score:.3f}" if score is not None else "no score" + self.record({ + "role": "skill", + "content": f"activated skill '{skill.name}' (trigger={trigger}, {detail})", + "created_at": now(), + }) + + def select_skill(self, user_message): + # Returns the request text with any explicit "/skill " prefix removed. + self.session["active_skill"] = None + if not self.skills or not self.skills.skills: + return user_message + self.skills.logger.log("prompt", prompt=user_message) + match = re.match(r"^/skill\s+(\S+)\s*(.*)$", user_message.strip(), re.S) + if match: + name, rest = match.group(1), match.group(2).strip() + skill = self.skills.get(name) + if skill is None: + self.skills.logger.log("skill_not_found", requested=name, available=list(self.skills.skills)) + else: + self.activate_skill(skill, "explicit") + return rest or skill.description + skill, score = self.skills.match(user_message) + if skill is not None: + self.activate_skill(skill, "auto", score) + return user_message + + def tool_use_skill(self, args): + name = str(args.get("name", "")).strip() + skill = self.skills.get(name) if self.skills else None + if skill is None: + available = list(self.skills.skills) if self.skills else [] + if self.skills: + self.skills.logger.log("skill_not_found", requested=name, available=available) + raise ValueError(f"unknown skill '{name}'; available: {', '.join(available) or '-'}") + active = self.session.get("active_skill") + if active and active["name"] == skill.name: + return f"skill '{skill.name}' is already active; follow the Active skill instructions." + self.activate_skill(skill, "tool") + return f"skill '{skill.name}' activated; its instructions are now in the Active skill section of the prompt." ############################################### #### 5) Session Memory (Continued) ########### @@ -447,6 +821,7 @@ def ask(self, user_message): if not memory["task"]: memory["task"] = clip(user_message.strip(), 300) self.record({"role": "user", "content": user_message, "created_at": now()}) + user_message = self.select_skill(user_message) tool_steps = 0 attempts = 0 @@ -530,6 +905,7 @@ def tool_example(self, name): "write_file": 'def binary_search(nums, target):\n return -1\n', "patch_file": 'return -1return mid', "delegate": '{"name":"delegate","args":{"task":"inspect README.md","max_steps":3}}', + "use_skill": '{"name":"use_skill","args":{"name":"pr-review"}}', } return examples.get(name, "") @@ -591,6 +967,11 @@ def validate_tool(self, name, args): raise ValueError(f"old_text must occur exactly once, found {count}") return + if name == "use_skill": + if not str(args.get("name", "")).strip(): + raise ValueError("name must not be empty") + return + if name == "delegate": if self.depth >= self.max_depth: raise ValueError("delegate depth exceeded") @@ -670,7 +1051,7 @@ def parse_xml_tool(raw): body = match.group("body") args = dict(attrs) - for key in ("content", "old_text", "new_text", "command", "task", "pattern", "path"): + for key in ("content", "old_text", "new_text", "command", "task", "pattern", "path", "name"): if f"<{key}>" in body: args[key] = MiniAgent.extract_raw(body, key) @@ -717,6 +1098,7 @@ def extract_raw(text, tag): def reset(self): self.session["history"] = [] self.session["memory"] = {"task": "", "files": [], "notes": []} + self.session["active_skill"] = None self.session_store.save(self.session) def path_is_within_root(self, resolved): @@ -909,9 +1291,18 @@ def pair(left_label, left_value, right_label, right_value): return "\n".join([line, *rows, line]) +def build_skills(args, workspace): + if getattr(args, "no_skills", False): + return None + logger = SkillLogger(Path(workspace.repo_root) / ".mini-coding-agent" / "logs" / "skills.jsonl") + roots = SkillRegistry.default_roots(workspace, getattr(args, "skills_dir", None) or []) + return SkillRegistry.discover(roots, threshold=args.skill_threshold, logger=logger) + + def build_agent(args): workspace = WorkspaceContext.build(args.cwd) store = SessionStore(Path(workspace.repo_root) / ".mini-coding-agent" / "sessions") + skills = build_skills(args, workspace) model = OllamaModelClient( model=args.model, host=args.host, @@ -931,6 +1322,7 @@ def build_agent(args): approval_policy=args.approval, max_steps=args.max_steps, max_new_tokens=args.max_new_tokens, + skills=skills, ) return MiniAgent( model_client=model, @@ -939,6 +1331,7 @@ def build_agent(args): approval_policy=args.approval, max_steps=args.max_steps, max_new_tokens=args.max_new_tokens, + skills=skills, ) @@ -963,6 +1356,9 @@ def build_arg_parser(): parser.add_argument("--max-new-tokens", type=int, default=512, help="Maximum model output tokens per step.") parser.add_argument("--temperature", type=float, default=0.2, help="Sampling temperature sent to Ollama.") parser.add_argument("--top-p", type=float, default=0.9, help="Top-p sampling value sent to Ollama.") + parser.add_argument("--skills-dir", action="append", default=[], help="Extra folder of skills (repeatable).") + parser.add_argument("--skill-threshold", type=float, default=SKILL_THRESHOLD, help="Minimum similarity score to auto-activate a skill.") + parser.add_argument("--no-skills", action="store_true", help="Disable the agent skills component.") return parser @@ -1003,6 +1399,9 @@ def main(argv=None): if user_input == "/session": print(agent.session_path) continue + if user_input == "/skills": + print(agent.skills.catalogue() if agent.skills and agent.skills.skills else "no skills found") + continue if user_input == "/reset": agent.reset() print("session reset") diff --git a/pyproject.toml b/pyproject.toml index 395b699..81dea5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,3 +22,6 @@ dev = [ [tool.setuptools] py-modules = ["mini_coding_agent"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/sdlc/01-intent.md b/sdlc/01-intent.md new file mode 100644 index 0000000..d95ba57 --- /dev/null +++ b/sdlc/01-intent.md @@ -0,0 +1,62 @@ +# 01 — Intent + +**SDLC stage:** Requirements / Intent +**Project:** Mini-Coding-Agent — Component 7: Agent Skills + +## Problem + +The mini coding agent (rasbt/mini-coding-agent) is built around six components: + +1. Live repo context +2. Prompt shape and cache reuse +3. Structured tools, validation, and permissions +4. Context reduction and output management +5. Transcripts, memory, and resumption +6. Delegation and bounded subagents + +It has no way to load **reusable, task-specific know-how**. Every time we ask +it to do something we have done before (e.g. "build a vacuum-world agent from a +spec", "review this PR"), we must re-explain the whole procedure in the prompt. +With a 4B local model this matters a lot: the model needs explicit, step-by-step +instructions to succeed. + +## Goal + +Add a seventh component, **Agent Skills**, so that the agent can: + +- **Discover** skills stored as folders, one skill per folder, each with a + `SKILL.md` file (e.g. `skills/pr-review/SKILL.md`, + `skills/vacuum-agent-from-spec/SKILL.md`). +- **Select** the right skill automatically when a user prompt is similar enough + to a skill's description (a *similarity check*). +- **Trigger** a skill on demand during execution, when the model decides it + needs one (a `use_skill` tool). +- **Execute** the skill: inject the `SKILL.md` instructions into the prompt so + the model follows them step by step. +- **Log** every decision: the prompt, the similarity scores for every skill, + and which skill was executed (and how it was triggered). + +## Success criteria + +| # | Criterion | How we check it | +|---|-----------|-----------------| +| S1 | Skills are discovered from `skills//SKILL.md` (and `skill//SKILL.md`) | unit test | +| S2 | A matching prompt auto-activates the right skill; an unrelated prompt does not | unit test | +| S3 | The model can activate a skill mid-run with `use_skill` | unit test with FakeModelClient | +| S4 | Prompt, similarity check and executed skill are logged to a JSONL file and the console | unit test + live run log | +| S5 | The Lab 5 skill is packaged as a skill folder | file exists, parsed by registry | +| S6 | The mini agent, using that skill, solves Lab 4 (Strategy 1 persistent-memory vacuum agent) and the Lab 4 test suite passes | live run with Ollama `qwen3.5:4b` | +| S7 | All existing tests still pass; no new runtime dependencies (stdlib only) | `pytest` | + +## Non-goals + +- Embedding models / vector databases. The similarity check must be stdlib-only + to keep the project dependency-free like the original. +- Skills that run arbitrary code automatically. A skill is *instructions*; any + command still goes through the existing tool + approval layer (component 3). +- Changing how the other six components work. + +## Stakeholders + +- Students / instructor of CS440 (grading the component and the SDLC artifacts). +- Users of the mini agent who want reusable workflows. diff --git a/sdlc/02-spec.md b/sdlc/02-spec.md new file mode 100644 index 0000000..199b75d --- /dev/null +++ b/sdlc/02-spec.md @@ -0,0 +1,141 @@ +# 02 — Specification + +**SDLC stage:** Specification / Design +**Implements:** [01-intent.md](01-intent.md) + +## 1. Skill folder format + +A skill is a folder containing a `SKILL.md` file. Any other files in the folder +(templates, reference notes, scripts) are resources the instructions may point +to. + +``` +skills/ +├── pr-review/ +│ └── SKILL.md +└── vacuum-world-agent/ + ├── SKILL.md + └── reference.md +``` + +`SKILL.md` starts with a small front-matter block, followed by the +instructions in Markdown: + +```markdown +--- +name: vacuum-world-agent +description: Implement an AIMA vacuum-world agent in Python from a spec.md ... +triggers: vacuum world, vacuum agent, spec.md, persistent memory, q-learning +--- + +# Instructions +1. ... +``` + +| Field | Required | Meaning | +|-------|----------|---------| +| `name` | no (defaults to the folder name) | unique id used by `use_skill` and `/skill` | +| `description` | yes | one or two sentences; the main text used for similarity | +| `triggers` | no | comma-separated phrases; an exact phrase hit in the prompt boosts the score | +| `context` | no | comma-separated workspace files or globs (e.g. `spec.md, Test/*.py`) loaded into the prompt while the skill is active. Paths go through the normal workspace validation; missing files are skipped. *(added in iteration 2, see 05-verification.md)* | +| `resources` | no | comma-separated files inside the skill folder (e.g. `reference.md`) loaded the same way | + +Loaded files are clipped to 9000 chars each and 20000 chars in total, and +are listed in the `skill_executed` log event as `loaded_files`. + +Folders without a `SKILL.md`, or with a `SKILL.md` that has no description, +are skipped (and the reason is logged). + +## 2. Discovery (`SkillRegistry.discover`) + +Search roots, in priority order (first skill with a given name wins): + +1. every `--skills-dir` passed on the command line +2. `/skills`, `/skill` +3. `/skills`, `/skill` +4. `/skills` (skills shipped with the agent) + +Each direct sub-folder of a root that contains `SKILL.md` is one skill. + +## 3. Similarity check (`SkillRegistry.match`) + +Standard library only. For each skill: + +- **Tokenize** the prompt and the skill document + (`name` + `description` + `triggers`, triggers counted twice): + lower-case, split on non-alphanumerics, drop stop-words and 1-char tokens, + light suffix stemming (`-ing`, `-ed`, `-es`, `-s`). +- **TF-IDF cosine**: IDF is computed over the skill corpus with smoothing + `idf = ln((N + 1) / (df + 1)) + 1`. +- **Trigger bonus**: `trigger = 1.0` if any trigger phrase appears verbatim + (case-insensitive) in the prompt, else `0.0`. +- **Score**: `score = 0.6 * cosine + 0.4 * trigger`, in `[0, 1]`. + +The best-scoring skill is selected if `score >= threshold` +(default `0.20`, set with `--skill-threshold`). Ties go to the first skill in +discovery order. If no skill reaches the threshold, no skill is activated. + +## 4. Triggering + +| Trigger | When | Source label in log | +|---------|------|---------------------| +| Automatic | at the start of every `ask()`, the similarity check runs on the user prompt | `auto` | +| Explicit | user prompt starts with `/skill ` (REPL or one-shot) | `explicit` | +| On demand | the model calls the tool `use_skill(name)` during execution | `tool` | + +The skill catalogue (name + description of every skill) is part of the stable +prompt prefix, so the model always knows which skills exist and can choose +`use_skill` when it realizes it needs one mid-task. + +## 5. Execution + +"Executing" a skill means making its instructions the governing procedure for +the current request: + +- The active skill is stored in `session["active_skill"]` + (`name`, `trigger`, `score`, `path`). +- `prompt()` inserts an **Active skill** section between the memory and the + transcript, containing the skill directory and the full `SKILL.md` body + (clipped to 6000 chars) plus the rule *"Follow these instructions step by + step."* +- A `{"role": "skill", ...}` entry is added to the session history so the + transcript shows when and why a skill became active. +- The active skill lasts for the current `ask()`; the next user request runs + a fresh similarity check. +- Tool calls requested by the skill still go through validation and approval + (component 3). Skills cannot bypass permissions. + +## 6. Logging (`SkillLogger`) + +Every skill event is written as one JSON line to +`/.mini-coding-agent/logs/skills.jsonl` and echoed to **stderr** +with a `[skills]` prefix (stdout stays clean for answers). + +| Event | Fields | +|-------|--------| +| `discover` | `roots`, `skills` (names), `skipped` | +| `prompt` | `prompt` | +| `similarity_check` | `prompt`, `threshold`, `scores` (list of `{skill, score, cosine, trigger_hits}` sorted high→low), `selected` | +| `skill_executed` | `skill`, `trigger` (`auto`/`explicit`/`tool`), `score`, `path`, `loaded_files` | +| `skill_not_found` | `requested`, `available` | + +All events also carry `ts` (UTC ISO-8601) and `session_id`. + +## 7. CLI / REPL surface + +- `--skills-dir PATH` (repeatable), `--skill-threshold FLOAT`, `--no-skills` +- `/skills` — list skills +- `/skill ` — force a skill for this request + +## 8. Lab 5 skill → Lab 4 task + +- The Lab 5 workflow (spec-driven generation of AIMA vacuum-world agents: + read `spec.md`, read the tests, write the agent file in the required verbose + style, run the tests, fix failures) is packaged as + `skills/vacuum-world-agent/`. +- Lab 4 (Strategy 1, `PersistentMemoryAgent`) is reproduced in a clean + workspace `lab4_skill_run/` that contains only `spec.md` and the Lab 4 test + file — **no** solution. +- The mini agent is run against that workspace with a plain prompt; the + similarity check must select `vacuum-world-agent`, and the resulting + `agent_strategy1.py` must pass `Test/test.py`. diff --git a/sdlc/03-plan.md b/sdlc/03-plan.md new file mode 100644 index 0000000..f8e007c --- /dev/null +++ b/sdlc/03-plan.md @@ -0,0 +1,49 @@ +# 03 — Plan + +**SDLC stage:** Planning +**Implements:** [02-spec.md](02-spec.md) + +## Work breakdown + +| # | Task | Files | Verifies | +|---|------|-------|----------| +| T1 | Write intent, spec, plan | `sdlc/01..03` | — | +| T2 | `Skill` data class + `parse_skill_file` (front-matter parser, stdlib only) | `mini_coding_agent.py` | S1 | +| T3 | `SkillRegistry.discover` over the search roots | `mini_coding_agent.py` | S1 | +| T4 | Tokenizer + TF-IDF cosine + trigger bonus → `SkillRegistry.match` | `mini_coding_agent.py` | S2 | +| T5 | `SkillLogger` (JSONL + stderr) | `mini_coding_agent.py` | S4 | +| T6 | Wire into `MiniAgent`: skill catalogue in prefix, `activate_skill`, auto match in `ask()`, `/skill` explicit prefix, Active-skill prompt section, `use_skill` tool | `mini_coding_agent.py` | S2, S3 | +| T7 | CLI flags and REPL commands (`/skills`, `/skill`) | `mini_coding_agent.py` | — | +| T8 | Package skills: `vacuum-world-agent` (Lab 5) and `pr-review` (example / distractor for the similarity check) | `skills/` | S5 | +| T9 | Unit tests for T2–T7 with `FakeModelClient` | `tests/test_skills.py` | S1–S4, S7 | +| T10 | Build clean Lab 4 workspace, run the mini agent with Ollama, run Lab 4 tests, capture logs | `lab4_skill_run/`, `sdlc/05-verification.md` | S6 | +| T11 | Update README with Component 7 | `README.md` | — | +| T12 | *(added after run 1)* Skill `context`/`resources` preloading + test | `mini_coding_agent.py`, `tests/test_skills.py` | S6 | + +## Order and dependencies + +``` +T1 → T2 → T3 → T4 → T5 → T6 → T7 → T9 + T8 ─────────┘ + T9 → T10 → T11 +``` + +## Design decisions + +- **Single file.** The original project is one file (`mini_coding_agent.py`) + organised by component banners; the new component follows that pattern under + a `7) Agent Skills` banner instead of adding a package. +- **TF-IDF + trigger phrases instead of embeddings.** Keeps zero runtime + dependencies and makes the score explainable in the log. +- **Skill body in the per-request part of the prompt, catalogue in the prefix.** + Matches component 2 (stable prefix for cache reuse; changing parts after it). +- **stderr for skill logs.** One-shot mode prints the final answer on stdout, + so piping the answer still works. + +## Risks + +| Risk | Mitigation | +|------|------------| +| Small model (qwen3.5:4b) ignores long instructions | Skill written as short numbered steps; reference snippets in a separate `reference.md` the model reads with `read_file` | +| Default `max_new_tokens=512` too small to write a whole agent file | Run Lab 4 with `--max-new-tokens 4096 --max-steps 14` | +| False-positive skill activation | Threshold + distractor skill + negative unit test | diff --git a/sdlc/04-tests.md b/sdlc/04-tests.md new file mode 100644 index 0000000..b59fba3 --- /dev/null +++ b/sdlc/04-tests.md @@ -0,0 +1,47 @@ +# 04 — Tests + +**SDLC stage:** Implementation + Testing +**Implements:** [03-plan.md](03-plan.md) + +## Unit tests — `tests/test_skills.py` + +All tests use `FakeModelClient`, so they run without Ollama. + +| Test | Criterion | +|------|-----------| +| `test_parse_skill_file_reads_front_matter_and_body` | S1 | +| `test_parse_skill_file_defaults_name_to_folder` | S1 | +| `test_discover_finds_skill_folders_and_skips_invalid` (uses `skill/` singular) | S1 | +| `test_discover_first_root_wins_on_duplicate_names` | S1 | +| `test_default_roots_include_skills_and_skill_folders` | S1 | +| `test_repo_ships_lab5_skill` | S5 | +| `test_tokenize_drops_stop_words_and_stems` | S2 | +| `test_match_selects_best_skill` | S2 | +| `test_match_returns_none_below_threshold` | S2 | +| `test_scores_are_sorted_and_explainable` | S2, S4 | +| `test_auto_trigger_injects_skill_instructions_into_prompt` | S2 | +| `test_unrelated_prompt_does_not_activate_skill` | S2 | +| `test_explicit_skill_command` | S2 | +| `test_model_can_trigger_skill_during_execution` | S3 | +| `test_use_skill_unknown_name_returns_error` | S3 | +| `test_use_skill_xml_form_is_parsed` | S3 | +| `test_active_skill_resets_on_next_request` | spec §5 | +| `test_agent_without_skills_has_no_skill_tool` | S7 (no behaviour change when no skills) | +| `test_log_records_prompt_similarity_and_executed_skill` | S4 | +| `test_log_records_tool_trigger` | S4 | +| `test_cli_flags_build_registry` | spec §7 | +| `test_skill_context_and_resources_are_loaded_into_prompt` | spec §1 (`context`/`resources`), S4 (`loaded_files`) | + +## Regression + +The original `tests/test_mini_coding_agent.py` is unchanged and must keep +passing (S7). `pyproject.toml` now sets `testpaths = ["tests"]` so the lab +sub-folders' own tests are not collected by accident. + +## Run + +```bash +uv run --with pytest python -m pytest -q +``` + +Result at time of writing: **41 passed** (19 original + 22 new). diff --git a/sdlc/05-verification.md b/sdlc/05-verification.md new file mode 100644 index 0000000..e1adc33 --- /dev/null +++ b/sdlc/05-verification.md @@ -0,0 +1,78 @@ +# 05 — Verification + +**SDLC stage:** Verification / Validation (with iteration back to spec + implementation) +**Verifies:** [01-intent.md](01-intent.md) success criteria + +## Setup + +- Model: Ollama `qwen3.5:4b` (local, 32k context). +- Workspace: a clean folder containing only `README.md`, Lab 4's `spec.md` + (Strategy 1, persistent memory) and Lab 4's `Test/test.py`. **No solution file.** +- Command: + +```bash +python mini_coding_agent.py --cwd --approval auto \ + --max-steps 14 --max-new-tokens 6144 --ollama-timeout 600 \ + "Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass." +``` + +The prompt does not name the skill. Selection is left to the similarity check. + +## Iterations + +| Run | Change before the run | Result | +|-----|-----------------------|--------| +| 1 | — (first implementation) | Skill auto-selected (0.590). The model then looped on `read_file spec.md` until the step limit. **Cause:** component 4 clips each tool result to 900 chars in the transcript, so the 4B model never saw the full 7.5k-char spec. **Fail.** | +| 2 | Added `context` / `resources` to the skill format (spec §1). The skill preloads `spec.md`, `Test/*.py` and `reference.md` into the Active-skill section. | Wrote `agent_strategy1.py` with one `write_file` call. `pytest -q Test` collected **0 tests**, because the file is named `test.py`. The model recovered with `pytest Test/test.py` → **5 passed**. The `__main__` demo crashed (it used `DummyVacuumWorld` from the tests). **Pass, with skill defects.** | +| 3 | Skill fixes: run `pytest -q Test/*.py`; the demo must define its own env; run the file after the tests. | Skill auto-selected (0.590). After 5 malformed `write_file` attempts, which the runtime rejected and the model retried, it wrote the file. Ran `pytest -q Test/*.py` → **5 passed**. Final answer: `Done.` **Pass.** | + +Artifacts from run 3 are in [`../lab4_skill_run/`](../lab4_skill_run/). +Run 2's session transcript and run 1's console log are in +`lab4_skill_run/logs/earlier_iterations/`. + +## Skill log from run 3 (`lab4_skill_run/logs/console_stderr.txt`) + +``` +[skills] discovered 2 skill(s): pr-review, vacuum-world-agent +[skills] prompt: 'Solve Lab 4: implement the agent described in spec.md so that the tests in Test/ pass.' +[skills] similarity (threshold 0.20): vacuum-world-agent=0.590, pr-review=0.011 -> selected: vacuum-world-agent +[skills] executing skill 'vacuum-world-agent' (trigger=auto, score=0.590) from .../skills/vacuum-world-agent/SKILL.md; loaded files: spec.md, Test/test.py, vacuum-world-agent/reference.md +``` + +The same events are stored as JSON lines in `lab4_skill_run/logs/skills.jsonl` +(`discover`, `prompt`, `similarity_check` with all scores, `skill_executed`). + +## Success criteria + +| # | Criterion | Status | Evidence | +|---|-----------|--------|----------| +| S1 | Skill folders discovered | ✅ | unit tests; `discover` log event | +| S2 | Matching prompt auto-activates the right skill; unrelated prompt doesn't | ✅ | unit tests; live score 0.590 vs 0.011 | +| S3 | Model can trigger a skill mid-run (`use_skill`) | ✅ | `test_model_can_trigger_skill_during_execution` (not exercised in the live run, because auto-selection already fired) | +| S4 | Prompt, similarity check, executed skill logged | ✅ | `skills.jsonl`, stderr | +| S5 | Lab 5 skill packaged as a folder | ✅ | `skills/vacuum-world-agent/` | +| S6 | Mini agent + skill solves Lab 4; Lab 4 tests pass | ✅ | `cd lab4_skill_run && python -m pytest -q Test/test.py` → `5 passed` | +| S7 | Existing tests pass; stdlib only | ✅ | `41 passed` | + +## Known limitations + +- **Demo still crashes in run 3.** The model skipped the skill's "run the + file" step and returned `Done.` right after the tests passed. Its + `__main__` demo then raises `KeyError: 'SUCK'` (it treats `SUCK` as a + move). The Lab 4 tests don't cover the demo, so S6 still passes, but the + spec's "standalone demo" requirement is not met by this generated file. + This comes from the small model's reliability, not from the skill + component. +- Results with a 4B model vary from run to run. Run 3 is one recorded run, + not a guarantee. +- Similarity is lexical (TF-IDF). A paraphrase with no shared words (e.g. + "build the thing for lab 4" scores 0.165) won't auto-trigger. The model + can still call `use_skill`, or the user can type `/skill vacuum-world-agent ...`. + +## About the Lab 5 skill + +The Lab 5 repository has no `SKILL.md` checked in. `skills/vacuum-world-agent` +was written from Lab 5's spec-driven workflow (spec → generate → test) and +its Q-learning notes. If the team has its own Lab 5 `SKILL.md`, drop it into +`skills//SKILL.md`, add `context:` / `resources:` lines if needed, and +rerun the command above. diff --git a/skills/pr-review/SKILL.md b/skills/pr-review/SKILL.md new file mode 100644 index 0000000..d562819 --- /dev/null +++ b/skills/pr-review/SKILL.md @@ -0,0 +1,22 @@ +--- +name: pr-review +description: Review a pull request or the current git diff for bugs, missing tests and risky changes, and write a short review. +triggers: pull request, pr review, review the pr, code review, review my diff, git diff +--- + +# Skill: pr-review + +One tool call per reply. + +1. Run `git status --short` and `git diff --stat` with run_shell to see what changed. +2. Run `git diff` (add a path if the diff is long) and read each changed hunk. +3. For each changed file, read the surrounding code with read_file when the + hunk alone is not enough to judge it. +4. Check: logic errors, unhandled None / empty inputs, off-by-one, changed + behaviour without a test, secrets or debug prints left in. +5. If tests exist, run them with run_shell (`python -m pytest -q`). +6. Reply with `` containing: + - **Summary** — one sentence on what the change does. + - **Issues** — numbered, each with `file:line`, what is wrong, and a fix. + - **Tests** — what ran and the result. + - **Verdict** — approve / request changes. diff --git a/skills/vacuum-world-agent/SKILL.md b/skills/vacuum-world-agent/SKILL.md new file mode 100644 index 0000000..e192844 --- /dev/null +++ b/skills/vacuum-world-agent/SKILL.md @@ -0,0 +1,61 @@ +--- +name: vacuum-world-agent +description: Implement or fix a Python agent for the AIMA 4x4 vacuum world from a spec.md file (persistent memory agent, sub-agent orchestrator, or Q-learning agent), then run the pytest tests until they pass. +triggers: vacuum world, vacuum agent, vacuum-world, spec.md, persistent memory agent, PersistentMemoryAgent, SubAgentOrchestrator, q-learning, agent_strategy +context: spec.md, Test/*.py, tests/*.py +resources: reference.md +--- + +# Skill: vacuum-world-agent (CS440 Lab 5) + +Spec-driven workflow from Lab 5: the spec.md is the contract, the tests are +the judge. `spec.md`, the test files and `reference.md` are already loaded +below under "Skill files". Do not call read_file on them. One tool call per +reply. + +## Step 1 - Plan (no tool call needed) +From the loaded spec.md and tests, find: +- the exact file name to create (for example `agent_strategy1.py`), +- the exact class name and every method the tests call, +- the exact keys of `self.memory` and their initial values, +- the decision rules in priority order. +The tests decide the exact API. Copy names exactly. + +## Step 2 - Write the agent file in ONE write_file call +Use the XML form: +`...` +The file must: +- use only the Python standard library, +- define the class with `__init__(self, env)` that stores `self.env = env` + and builds `self.memory` with EVERY key from the spec, one per line, each + with a comment, +- implement `update_memory(percept)` as small numbered blocks that each call + `self._log(...)`, and increase `step_count` by exactly 1 per call, +- implement `program(percept)` as `if/elif` branches in the spec's rule order, + each with a `# Rule N:` comment, logging why the action was chosen, +- never read `env.grid`; only use the percept and memory, +- only return a move toward a neighbor whose percept value is not None, +- copy `_log`, `_neighbor_coord`, `_bfs_next_step` and `DIRECTIONS` from + reference.md, +- give every public method a docstring, +- end with an `if __name__ == "__main__":` demo that does not crash. The + demo must define its own small environment class IN THIS FILE (a 4x4 grid + with a `percept()` method like the one in the tests); never use a class + that only exists in the test file. + +## Step 3 - Run the tests +Call run_shell with +`{"command":"python -m pytest -q Test/*.py","timeout":60}` +(list the files explicitly: a file named `test.py` is not collected when +you pass only the folder). +Then call run_shell with `{"command":"python agent_strategy1.py","timeout":30}` +(use the real file name) to check that the demo runs without a traceback. + +## Step 4 - Fix and re-run +If any test fails or the demo crashes, read the assertion message, fix the smallest piece with +patch_file (or rewrite the file with write_file), then run the tests again. +Repeat until all tests pass. + +## Step 5 - Finish +Reply with `` listing the file written, the class name and the test +result line (for example `5 passed`). diff --git a/skills/vacuum-world-agent/reference.md b/skills/vacuum-world-agent/reference.md new file mode 100644 index 0000000..a4a22a4 --- /dev/null +++ b/skills/vacuum-world-agent/reference.md @@ -0,0 +1,103 @@ +# vacuum-world-agent — reference snippets (Lab 5) + +## Percept format (what the agent receives every step) + +```python +percept = { + "pos": (row, col), # 0-indexed, grid is 4x4 + "cell": 0, # 0, -1, 10, or "start" + "dirty": False, # True means the agent should SUCK + "neighbors": { # None = wall / out of bounds / obstacle "X" + "UP": 0, "DOWN": None, "LEFT": None, "RIGHT": 0, + }, +} +``` + +## Direction table + +```python +DIRECTIONS = { + "UP": (-1, 0), + "DOWN": (1, 0), + "LEFT": (0, -1), + "RIGHT": (0, 1), +} +GRID_SIZE = 4 +``` + +## Helper methods (put these inside the agent class) + +```python + def _log(self, pos, note): + """Append a history entry and print a trace line.""" + step = self.memory["step_count"] + self.memory["history"].append((step, pos, self.memory["last_action"], note)) + print(f"[step {step}] pos={pos} action={self.memory['last_action']} -> {note}") + + def _neighbor_coord(self, pos, direction): + """Return the (r, c) one step in direction, or None if out of bounds.""" + dr, dc = DIRECTIONS[direction] + r, c = pos[0] + dr, pos[1] + dc + if 0 <= r < GRID_SIZE and 0 <= c < GRID_SIZE: + return (r, c) + return None + + def _bfs_next_step(self, start, goal): + """Return the first action of a BFS path from start to goal, or None.""" + from collections import deque + if start == goal: + return None + queue = deque([(start, None)]) + seen = {start} + while queue: + pos, first = queue.popleft() + for direction in ("UP", "DOWN", "LEFT", "RIGHT"): + nxt = self._neighbor_coord(pos, direction) + if nxt is None or nxt in seen or nxt in self.memory["obstacles"]: + continue + step = first or direction + if nxt == goal: + return step + seen.add(nxt) + queue.append((nxt, step)) + return None +``` + +## Decision rule pattern (`program`) + +```python + def program(self, percept): + """Return the next action using memory + percept.""" + pos = percept["pos"] + self.update_memory(percept) + + # Rule 1: current cell is dirty -> SUCK + if percept["dirty"]: + action = "SUCK" + self._log(pos, "chose SUCK (reason: current cell dirty)") + # Rule 2: move toward a known dirty cell + elif ...: + ... + # Rule 3: explore an unvisited, open neighbor + elif ...: + ... + # Rule 4: BFS toward nearest unvisited known-safe cell + elif ...: + ... + # Rule 5: any legal move + else: + ... + self.memory["last_action"] = action + return action +``` + +A neighbor is legal only if `percept["neighbors"][direction] is not None`. +Never return "UP"/"LEFT" from (0, 0). + +## Q-learning update (Lab 5) + +```python +new_q = old_q + alpha * (reward + gamma * max_future_q - old_q) +``` +Epsilon-greedy: with probability epsilon pick a random valid action, +otherwise the action with the highest Q value (break ties randomly). diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..7dbc857 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,307 @@ +import json +from pathlib import Path + +from mini_coding_agent import ( + FakeModelClient, + MiniAgent, + SessionStore, + SkillLogger, + SkillRegistry, + WorkspaceContext, + build_arg_parser, + build_skills, + parse_skill_file, + tokenize, +) + +REPO_SKILLS = Path(__file__).resolve().parent.parent / "skills" + + +def write_skill(root, folder, name, description, triggers="", body="Do the thing."): + path = root / folder + path.mkdir(parents=True, exist_ok=True) + lines = ["---"] + if name: + lines.append(f"name: {name}") + if description: + lines.append(f"description: {description}") + if triggers: + lines.append(f"triggers: {triggers}") + lines += ["---", "", body, ""] + (path / "SKILL.md").write_text("\n".join(lines), encoding="utf-8") + return path / "SKILL.md" + + +def make_registry(tmp_path, echo=False): + root = tmp_path / "skills" + write_skill( + root, "pr-review", "pr-review", + "Review a pull request or git diff for bugs and missing tests.", + "pull request, code review, review my diff", + body="1. Run git diff.\n2. Write the review.", + ) + write_skill( + root, "vacuum-world-agent", "vacuum-world-agent", + "Implement a Python agent for the AIMA vacuum world from a spec.md file.", + "vacuum world, spec.md, persistent memory agent", + body="1. Read spec.md.\n2. Write the agent.\n3. Run pytest.", + ) + logger = SkillLogger(tmp_path / ".mini-coding-agent" / "logs" / "skills.jsonl", echo=echo) + return SkillRegistry.discover([root], logger=logger) + + +def build_agent(tmp_path, outputs, registry=None, **kwargs): + (tmp_path / "README.md").write_text("demo\n", encoding="utf-8") + workspace = WorkspaceContext.build(tmp_path) + store = SessionStore(tmp_path / ".mini-coding-agent" / "sessions") + return MiniAgent( + model_client=FakeModelClient(outputs), + workspace=workspace, + session_store=store, + approval_policy="auto", + skills=registry if registry is not None else make_registry(tmp_path), + **kwargs, + ) + + +def read_log(tmp_path): + path = tmp_path / ".mini-coding-agent" / "logs" / "skills.jsonl" + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +# --- S1: discovery and parsing ------------------------------------------------- + +def test_parse_skill_file_reads_front_matter_and_body(tmp_path): + path = write_skill(tmp_path, "demo", "demo-skill", "A demo skill.", "alpha, beta gamma", body="Step 1.") + skill = parse_skill_file(path) + assert skill.name == "demo-skill" + assert skill.description == "A demo skill." + assert skill.triggers == ["alpha", "beta gamma"] + assert skill.body == "Step 1." + assert skill.directory == tmp_path / "demo" + + +def test_parse_skill_file_defaults_name_to_folder(tmp_path): + path = write_skill(tmp_path, "folder-name", None, "Desc.") + assert parse_skill_file(path).name == "folder-name" + + +def test_discover_finds_skill_folders_and_skips_invalid(tmp_path): + root = tmp_path / "skill" # singular folder name is also supported + write_skill(root, "good", "good", "Good skill.") + write_skill(root, "bad", "bad", "") # no description + (root / "empty").mkdir() # no SKILL.md + logger = SkillLogger(echo=False) + registry = SkillRegistry.discover([root, tmp_path / "missing"], logger=logger) + assert list(registry.skills) == ["good"] + event = logger.events[-1] + assert event["event"] == "discover" + assert event["skills"] == ["good"] + assert len(event["skipped"]) == 1 + + +def test_discover_first_root_wins_on_duplicate_names(tmp_path): + write_skill(tmp_path / "a", "x", "same", "From A.") + write_skill(tmp_path / "b", "x", "same", "From B.") + registry = SkillRegistry.discover([tmp_path / "a", tmp_path / "b"]) + assert registry.get("same").description == "From A." + + +def test_default_roots_include_skills_and_skill_folders(tmp_path): + (tmp_path / "README.md").write_text("demo\n", encoding="utf-8") + workspace = WorkspaceContext.build(tmp_path) + roots = SkillRegistry.default_roots(workspace, [tmp_path / "extra"]) + assert roots[0] == (tmp_path / "extra").resolve() + assert (tmp_path / "skills").resolve() in roots + assert (tmp_path / "skill").resolve() in roots + assert REPO_SKILLS in roots + + +def test_repo_ships_lab5_skill(): + registry = SkillRegistry.discover([REPO_SKILLS]) + assert "vacuum-world-agent" in registry.skills + assert "pr-review" in registry.skills + assert (registry.get("vacuum-world-agent").directory / "reference.md").is_file() + + +# --- S2: similarity check ------------------------------------------------------- + +def test_tokenize_drops_stop_words_and_stems(): + assert tokenize("Reviewing the tests for agents") == ["review", "test", "agent"] + + +def test_match_selects_best_skill(tmp_path): + registry = make_registry(tmp_path) + skill, score = registry.match("Implement the persistent memory agent in spec.md for the vacuum world") + assert skill.name == "vacuum-world-agent" + assert score >= registry.threshold + skill, _ = registry.match("Can you do a code review of my pull request?") + assert skill.name == "pr-review" + + +def test_match_returns_none_below_threshold(tmp_path): + registry = make_registry(tmp_path) + skill, score = registry.match("Write a binary search function") + assert skill is None and score is None + + +def test_scores_are_sorted_and_explainable(tmp_path): + registry = make_registry(tmp_path) + results = registry.scores("review my diff please") + assert [item["skill"] for item in results][0] == "pr-review" + assert results[0]["trigger_hits"] == ["review my diff"] + assert results == sorted(results, key=lambda item: item["score"], reverse=True) + assert all(0.0 <= item["score"] <= 1.0 for item in results) + + +# --- S2/S3: triggering and execution ------------------------------------------- + +def test_auto_trigger_injects_skill_instructions_into_prompt(tmp_path): + agent = build_agent(tmp_path, ["ok"]) + agent.ask("Implement the vacuum world agent from spec.md") + prompt = agent.model_client.prompts[0] + assert "Active skill: vacuum-world-agent" in prompt + assert "1. Read spec.md." in prompt + assert agent.session["active_skill"]["trigger"] == "auto" + assert any(item["role"] == "skill" for item in agent.session["history"]) + + +def test_unrelated_prompt_does_not_activate_skill(tmp_path): + agent = build_agent(tmp_path, ["ok"]) + agent.ask("Write a binary search function") + assert agent.session["active_skill"] is None + assert "Active skill:" not in agent.model_client.prompts[0] + + +def test_explicit_skill_command(tmp_path): + agent = build_agent(tmp_path, ["ok"]) + agent.ask("/skill pr-review check the latest change") + prompt = agent.model_client.prompts[0] + assert "Active skill: pr-review" in prompt + assert prompt.rstrip().endswith("check the latest change") + assert agent.session["active_skill"]["trigger"] == "explicit" + + +def test_model_can_trigger_skill_during_execution(tmp_path): + agent = build_agent( + tmp_path, + [ + '{"name":"use_skill","args":{"name":"vacuum-world-agent"}}', + "done", + ], + ) + agent.ask("build the thing for lab 4") + first, second = agent.model_client.prompts + assert "Active skill:" not in first + assert "use_skill(name: str)" in first # tool advertised + assert "- vacuum-world-agent: Implement a Python agent" in first # catalogue in prefix + assert "Active skill: vacuum-world-agent" in second + assert agent.session["active_skill"]["trigger"] == "tool" + + +def test_use_skill_unknown_name_returns_error(tmp_path): + agent = build_agent( + tmp_path, + ['{"name":"use_skill","args":{"name":"nope"}}', "done"], + ) + agent.ask("anything") + tool_events = [item for item in agent.session["history"] if item["role"] == "tool"] + assert "unknown skill 'nope'" in tool_events[0]["content"] + + +def test_use_skill_xml_form_is_parsed(): + kind, payload = MiniAgent.parse('pr-review') + assert kind == "tool" + assert payload == {"name": "use_skill", "args": {"name": "pr-review"}} + + +def test_active_skill_resets_on_next_request(tmp_path): + agent = build_agent(tmp_path, ["a", "b"]) + agent.ask("Implement the vacuum world agent from spec.md") + agent.ask("Write a binary search function") + assert agent.session["active_skill"] is None + + +def test_agent_without_skills_has_no_skill_tool(tmp_path): + agent = build_agent(tmp_path, ["ok"], registry=SkillRegistry([])) + assert "use_skill" not in agent.tools + agent.ask("Implement the vacuum world agent from spec.md") + assert "Agent skills" not in agent.model_client.prompts[0] + + +# --- S4: logging ---------------------------------------------------------------- + +def test_log_records_prompt_similarity_and_executed_skill(tmp_path, capsys): + agent = build_agent(tmp_path, ["ok"], registry=make_registry(tmp_path, echo=True)) + agent.ask("Implement the vacuum world agent from spec.md") + events = read_log(tmp_path) + kinds = [event["event"] for event in events] + assert kinds == ["discover", "prompt", "similarity_check", "skill_executed"] + prompt_event, check, executed = events[1:] + assert prompt_event["prompt"] == "Implement the vacuum world agent from spec.md" + assert check["selected"] == "vacuum-world-agent" + assert {item["skill"] for item in check["scores"]} == {"pr-review", "vacuum-world-agent"} + assert executed["skill"] == "vacuum-world-agent" + assert executed["trigger"] == "auto" + assert executed["session_id"] == agent.session["id"] + err = capsys.readouterr().err + assert "[skills] prompt:" in err + assert "[skills] similarity" in err + assert "[skills] executing skill 'vacuum-world-agent'" in err + + +def test_log_records_tool_trigger(tmp_path): + agent = build_agent( + tmp_path, + ['{"name":"use_skill","args":{"name":"pr-review"}}', "done"], + ) + agent.ask("look at stuff") + events = read_log(tmp_path) + assert events[-1]["event"] == "skill_executed" + assert events[-1]["trigger"] == "tool" + assert any(event["event"] == "similarity_check" and event["selected"] is None for event in events) + + +# --- CLI ------------------------------------------------------------------------ + +def test_cli_flags_build_registry(tmp_path): + (tmp_path / "README.md").write_text("demo\n", encoding="utf-8") + write_skill(tmp_path / "custom", "mine", "mine", "My custom skill.") + workspace = WorkspaceContext.build(tmp_path) + args = build_arg_parser().parse_args(["--skills-dir", str(tmp_path / "custom"), "--skill-threshold", "0.5"]) + registry = build_skills(args, workspace) + assert "mine" in registry.skills + assert registry.threshold == 0.5 + assert (tmp_path / ".mini-coding-agent" / "logs" / "skills.jsonl").is_file() + args = build_arg_parser().parse_args(["--no-skills"]) + assert build_skills(args, workspace) is None + + +# --- skill context files and resources ----------------------------------------- + +def test_skill_context_and_resources_are_loaded_into_prompt(tmp_path): + root = tmp_path / "skills" + write_skill(root, "ctx", "ctx", "Context demo skill for widgets.", "widget") + skill_md = root / "ctx" / "SKILL.md" + skill_md.write_text( + skill_md.read_text(encoding="utf-8").replace( + "triggers: widget", "triggers: widget\ncontext: spec.md, Test/*.py, missing.md, ../outside.md\nresources: ref.md" + ), + encoding="utf-8", + ) + (root / "ctx" / "ref.md").write_text("REFERENCE BODY", encoding="utf-8") + (tmp_path / "spec.md").write_text("SPEC BODY", encoding="utf-8") + (tmp_path / "Test").mkdir() + (tmp_path / "Test" / "test_a.py").write_text("TEST BODY", encoding="utf-8") + (tmp_path.parent / "outside.md").write_text("OUTSIDE", encoding="utf-8") + logger = SkillLogger(tmp_path / ".mini-coding-agent" / "logs" / "skills.jsonl", echo=False) + registry = SkillRegistry.discover([root], logger=logger) + agent = build_agent(tmp_path, ["ok"], registry=registry) + agent.ask("build a widget") + prompt = agent.model_client.prompts[0] + assert '\nSPEC BODY' in prompt + assert '\nTEST BODY' in prompt + assert '\nREFERENCE BODY' in prompt + assert "OUTSIDE" not in prompt # workspace path validation still applies + executed = [event for event in read_log(tmp_path) if event["event"] == "skill_executed"][0] + assert executed["loaded_files"] == ["spec.md", "Test/test_a.py", "ctx/ref.md"]