| """ |
| Pip's Errand |
| ============ |
| Send a small familiar on an errand through Thousand Token Wood. Pip can only see the |
| room it is in, so it must explore, remember, fetch, and deliver. You watch its |
| think-and-act loop unfold, one step at a time, across a little map. |
| |
| Track 2 toy for the "Small Models Big Adventures" hackathon. |
| Brain: openai/gpt-oss-20b (21B, Apache-2.0, under the 32B ceiling), a reasoning model, |
| run as a HOSTED model via Hugging Face Inference Providers. Swap it with the |
| PIP_MODEL env var. The Space runs on CPU and boots instantly. |
| |
| Engineering notes (why this survives a small model): |
| * tiny fixed action space: look | go DIR | take ITEM | drop ITEM | done |
| * strict parsing with lenient verb aliases, then validation against the world |
| * invalid or unparseable output falls back to a safe "look" instead of crashing |
| * a hard step cap guarantees termination |
| * success is checked DETERMINISTICALLY by the world, never by the model's say-so |
| * keeper mode (no token) solves the errand with BFS so the app always demos |
| |
| Setup: hardware CPU basic, plus a Space secret named HF_TOKEN. |
| """ |
|
|
| import os |
| import re |
| import html |
| import time |
| import copy |
| import inspect |
| from collections import deque |
|
|
| import gradio as gr |
|
|
| _BLOCKS_HAS_CSS = "css" in inspect.signature(gr.Blocks.__init__).parameters |
| _LAUNCH_HAS_SSR = "ssr_mode" in inspect.signature(gr.Blocks.launch).parameters |
|
|
| MODEL_ID = os.environ.get("PIP_MODEL", "openai/gpt-oss-20b") |
| HF_TOKEN = os.environ.get("HF_TOKEN", "").strip() |
| DEBUG = os.environ.get("PIP_DEBUG", "").strip().lower() in {"1", "true", "yes"} |
| MAX_STEPS = int(os.environ.get("PIP_MAX_STEPS", "16")) |
|
|
| |
| BASE_LOCATIONS = { |
| "clearing": {"desc": "A mossy clearing where three paths meet.", |
| "exits": {"north": "grove", "east": "brook", "south": "hollow"}, "items": []}, |
| "grove": {"desc": "A grove of silver birches.", |
| "exits": {"south": "clearing", "north": "thicket"}, "items": ["lantern"]}, |
| "thicket": {"desc": "A dim thicket, close and brambly.", |
| "exits": {"south": "grove"}, "items": ["mushroom"]}, |
| "brook": {"desc": "A chattering brook over smooth stones.", |
| "exits": {"west": "clearing", "east": "meadow"}, "items": ["key"]}, |
| "meadow": {"desc": "A sunlit meadow humming with bees.", |
| "exits": {"west": "brook"}, "items": ["honey"]}, |
| "hollow": {"desc": "A quiet hollow beneath an old root.", |
| "exits": {"north": "clearing", "south": "willow"}, "items": ["acorn"]}, |
| "willow": {"desc": "The old willow, ancient and kind.", |
| "exits": {"north": "hollow"}, "items": []}, |
| } |
| PRETTY = {"willow": "old willow"} |
| ITEMS_KNOWN = {"lantern", "key", "honey", "mushroom", "acorn"} |
| DIRS = {"n": "north", "s": "south", "e": "east", "w": "west", |
| "north": "north", "south": "south", "east": "east", "west": "west"} |
|
|
| NODES = {"thicket": (70, 35), "grove": (70, 95), "clearing": (170, 135), |
| "brook": (255, 135), "meadow": (320, 135), "hollow": (80, 185), "willow": (80, 228)} |
| EDGES = [("thicket", "grove"), ("grove", "clearing"), ("clearing", "brook"), |
| ("brook", "meadow"), ("clearing", "hollow"), ("hollow", "willow")] |
|
|
| PRESETS = [ |
| {"id": "lantern", "label": "Lantern to the willow", |
| "text": "Carry the lantern to the old willow.", "kind": "deliver", "item": "lantern", "dest": "willow"}, |
| {"id": "key", "label": "Fetch the silver key", |
| "text": "Fetch the silver key and bring it back to the clearing.", "kind": "fetch", "item": "key", "dest": "clearing"}, |
| {"id": "honey", "label": "Sweeten the willow's tea", |
| "text": "Bring something sweet to the old willow for tea.", "kind": "deliver", "item": "honey", "dest": "willow"}, |
| {"id": "mushroom", "label": "Mushroom to the brook", |
| "text": "Take a red mushroom down to the brook.", "kind": "deliver", "item": "mushroom", "dest": "brook"}, |
| ] |
|
|
|
|
| def pretty(name): |
| return PRETTY.get(name, name) |
|
|
|
|
| |
| _client = None |
| MODE = "keeper" |
|
|
|
|
| def load_model(): |
| global _client, MODE |
| if not HF_TOKEN: |
| MODE = "keeper" |
| print("[Pip] No HF_TOKEN secret set; keeper mode (BFS auto-pilot) active.") |
| return |
| try: |
| from huggingface_hub import InferenceClient |
| _client = InferenceClient(api_key=HF_TOKEN) |
| MODE = "model" |
| print("[Pip] Inference client ready -- model mode.") |
| except Exception as exc: |
| MODE = "keeper" |
| print(f"[Pip] Could not init inference client ({exc}). Keeper mode active.") |
|
|
|
|
| load_model() |
|
|
| AGENT_SYS = ( |
| "You are Pip, a small woodland familiar on an errand in Thousand Token Wood. You " |
| "move through the wood one step at a time. You can only see the room you are in, " |
| "so explore by moving, and remember what you have seen. Each step, choose ONE " |
| "action.\n\n" |
| "Reply in EXACTLY this format, two lines, nothing else:\n" |
| "THOUGHT: <one short sentence of reasoning>\n" |
| "ACTION: <one of: look | go DIRECTION | take ITEM | drop ITEM | done>\n\n" |
| "DIRECTION must be one of the exits shown. To pick something up use take; to leave " |
| "it use drop. Say done only when the errand is fully complete." |
| ) |
|
|
|
|
| def observe(state): |
| room = state["locations"][state["loc"]] |
| exits = ", ".join(room["exits"].keys()) or "none" |
| items = ", ".join(room["items"]) or "nothing" |
| inv = ", ".join(state["inv"]) or "nothing" |
| return (f"You are in the {pretty(state['loc'])}. {room['desc']} " |
| f"Exits: {exits}. Here: {items}. In your paws: {inv}.") |
|
|
|
|
| def _norm_dir(arg): |
| a = (arg or "").strip().lower().split() |
| for w in a: |
| if w in DIRS: |
| return DIRS[w] |
| return None |
|
|
|
|
| def _match_item(arg, pool): |
| a = (arg or "").strip().lower() |
| for it in pool: |
| if it in a or a in it: |
| return it |
| return None |
|
|
|
|
| def parse_action_line(line): |
| a = (line or "").strip().strip(".").strip().lower() |
| if not a: |
| return "look", "" |
| if a == "done" or a.startswith("done"): |
| return "done", "" |
| if a == "look" or a.startswith("look"): |
| return "look", "" |
| aliases = {"go": "go", "move": "go", "head": "go", "walk": "go", "step": "go", |
| "take": "take", "pick": "take", "grab": "take", "get": "take", |
| "drop": "drop", "leave": "drop", "place": "drop", "give": "drop", "put": "drop"} |
| for verb, canon in aliases.items(): |
| if a.startswith(verb): |
| return canon, a[len(verb):].strip() |
| if _norm_dir(a): |
| return "go", a |
| return "look", "" |
|
|
|
|
| def parse_step(raw): |
| raw = re.sub(r"<think>.*?</think>", "", raw or "", flags=re.S | re.I) |
| th = re.search(r"THOUGHT:\s*(.+)", raw, re.I) |
| ac = re.search(r"ACTION:\s*(.+)", raw, re.I) |
| thought = th.group(1).strip().splitlines()[0][:160] if th else "Pip considers the path." |
| action_line = ac.group(1).strip().splitlines()[0] if ac else "" |
| if not action_line: |
| m = re.search(r"\b(look|go|take|drop|done|move|head|walk|pick|grab|get|leave|place)\b.*", |
| raw, re.I) |
| if m: |
| action_line = m.group(0) |
| else: |
| d = re.search(r"\b(north|south|east|west)\b", raw, re.I) |
| action_line = d.group(0) if d else "look" |
| verb, arg = parse_action_line(action_line) |
| return thought, verb, arg |
|
|
|
|
| def model_step(state, steps): |
| recent = "".join(f"{s['action']} -> {s['result']}\n" for s in steps[-5:]) |
| user = (f"Errand: {state['errand']['text']}\n{observe(state)}\n" |
| f"Recently:\n{recent}\nWhat do you do?") |
| out = _client.chat_completion( |
| messages=[{"role": "system", "content": AGENT_SYS}, |
| {"role": "user", "content": user}], |
| model=MODEL_ID, max_tokens=120, temperature=0.4, |
| ) |
| return parse_step(out.choices[0].message.content or "") |
|
|
|
|
| |
| def fresh_state(errand): |
| return {"loc": "clearing", "inv": [], "locations": copy.deepcopy(BASE_LOCATIONS), |
| "visited": {"clearing"}, "errand": errand} |
|
|
|
|
| def execute(state, verb, arg): |
| loc = state["loc"] |
| room = state["locations"][loc] |
| if verb == "look": |
| return observe(state), True |
| if verb == "go": |
| d = _norm_dir(arg) |
| if d and d in room["exits"]: |
| state["loc"] = room["exits"][d] |
| state["visited"].add(state["loc"]) |
| return f"Pip pads {d} into the {pretty(state['loc'])}.", True |
| return f"There is no path {arg} from here.", False |
| if verb == "take": |
| it = _match_item(arg, room["items"]) |
| if it: |
| room["items"].remove(it) |
| state["inv"].append(it) |
| return f"Pip picks up the {it}.", True |
| return f"There is nothing like '{arg}' to take here.", False |
| if verb == "drop": |
| it = _match_item(arg, state["inv"]) |
| if it: |
| state["inv"].remove(it) |
| room["items"].append(it) |
| return f"Pip sets down the {it} in the {pretty(loc)}.", True |
| return f"Pip isn't carrying '{arg}'.", False |
| if verb == "done": |
| return "Pip pauses, wondering if the errand is finished.", True |
| return "Pip isn't sure how to do that.", False |
|
|
|
|
| def check_success(state): |
| e = state["errand"] |
| if e["kind"] == "deliver": |
| return e["item"] in state["locations"][e["dest"]]["items"] |
| return state["loc"] == e["dest"] and e["item"] in state["inv"] |
|
|
|
|
| |
| def _bfs(a, b, locations): |
| q = deque([[a]]) |
| seen = {a} |
| while q: |
| path = q.popleft() |
| if path[-1] == b: |
| return path |
| for dest in locations[path[-1]]["exits"].values(): |
| if dest not in seen: |
| seen.add(dest) |
| q.append(path + [dest]) |
| return [a] |
|
|
|
|
| def _path_dirs(a, b, locations): |
| path = _bfs(a, b, locations) |
| dirs = [] |
| for i in range(len(path) - 1): |
| for d, dest in locations[path[i]]["exits"].items(): |
| if dest == path[i + 1]: |
| dirs.append(d) |
| break |
| return dirs |
|
|
|
|
| def _find_item_room(locations, item): |
| for name, room in locations.items(): |
| if item in room["items"]: |
| return name |
| return "clearing" |
|
|
|
|
| def keeper_plan(errand): |
| locs = copy.deepcopy(BASE_LOCATIONS) |
| item, dest = errand["item"], errand["dest"] |
| item_room = _find_item_room(locs, item) |
| acts = [("go", d) for d in _path_dirs("clearing", item_room, locs)] |
| acts.append(("take", item)) |
| acts += [("go", d) for d in _path_dirs(item_room, dest, locs)] |
| if errand["kind"] == "deliver": |
| acts.append(("drop", item)) |
| acts.append(("done", "")) |
| return acts |
|
|
|
|
| def keeper_thought(verb, arg, errand): |
| if verb == "go": |
| return f"The {pretty(errand['dest'])} lies onward; I'll head {_norm_dir(arg) or arg}." |
| if verb == "take": |
| return f"I came for the {errand['item']}. Into my paws it goes." |
| if verb == "drop": |
| return "Here is where it belongs." |
| if verb == "done": |
| return "That should be the whole of it." |
| return "Let me look around." |
|
|
|
|
| |
| def esc(s): |
| return html.escape(str(s)) |
|
|
|
|
| def render_map(state): |
| cur, visited = state["loc"], state["visited"] |
| svg = "".join( |
| f'<line x1="{NODES[a][0]}" y1="{NODES[a][1]}" x2="{NODES[b][0]}" y2="{NODES[b][1]}" ' |
| f'stroke="var(--line)" stroke-width="2"/>' for a, b in EDGES) |
| for name, (x, y) in NODES.items(): |
| if name == cur: |
| svg += (f'<circle cx="{x}" cy="{y}" r="14" fill="none" stroke="var(--moss)" stroke-width="2"/>' |
| f'<circle cx="{x}" cy="{y}" r="9" fill="var(--moss)"/>') |
| tc, fw = "var(--ink)", "600" |
| elif name in visited: |
| svg += f'<circle cx="{x}" cy="{y}" r="7" fill="var(--line)"/>' |
| tc, fw = "var(--ink-soft)", "400" |
| else: |
| svg += f'<circle cx="{x}" cy="{y}" r="7" fill="#fffaf0" stroke="var(--line)" stroke-width="2"/>' |
| tc, fw = "var(--ink-soft)", "400" |
| svg += (f'<text x="{x}" y="{y + 23}" text-anchor="middle" font-size="11" ' |
| f'fill="{tc}" font-weight="{fw}" font-family="Spectral,serif">{esc(pretty(name))}</text>') |
| return f'<svg viewBox="0 0 360 250" width="100%" style="max-width:360px">{svg}</svg>' |
|
|
|
|
| def render(state, steps, done=False, gaveup=False): |
| log = "".join( |
| f'<div class="step"><div class="th">{esc(s["thought"])}</div>' |
| f'<div class="ac">{esc(s["action"])}</div>' |
| f'<div class="re">{esc(s["result"])}</div></div>' for s in steps) |
| inv = ", ".join(state["inv"]) or "nothing" |
| status = "" |
| if done: |
| status = '<div class="status win">The errand is complete. Pip pads home, pleased with itself.</div>' |
| elif gaveup: |
| status = ('<div class="status lost">Pip wanders back, the errand unfinished. ' |
| 'Perhaps another try?</div>') |
| side = (f'<div class="qside">{render_map(state)}' |
| f'<div class="qstate">In the <b>{esc(pretty(state["loc"]))}</b><br>' |
| f'Carrying: {esc(inv)}</div></div>') |
| head = f'<div class="qhead"><b>Errand:</b> <i>{esc(state["errand"]["text"])}</i></div>' |
| return f'{head}<div class="quest">{side}<div class="qlog">{log}{status}</div></div>' |
|
|
|
|
| def hint(msg): |
| return f'<div class="qhint">{esc(msg)}</div>' |
|
|
|
|
| def fmt_action(verb, arg): |
| if verb in ("look", "done"): |
| return verb |
| return f"{verb} {arg}".strip() |
|
|
|
|
| |
| def run(errand): |
| state = fresh_state(errand) |
| steps = [{"thought": f"My errand: {errand['text']}", "action": "set off", "result": observe(state)}] |
| yield render(state, steps) |
|
|
| plan = None if MODE == "model" else keeper_plan(errand) |
| for i in range(MAX_STEPS): |
| if MODE == "model": |
| try: |
| thought, verb, arg = model_step(state, steps) |
| except Exception as exc: |
| print(f"[Pip] step error: {exc}") |
| thought, verb, arg = "Pip hesitates a moment.", "look", "" |
| else: |
| if i >= len(plan): |
| break |
| verb, arg = plan[i] |
| thought = keeper_thought(verb, arg, errand) |
|
|
| result, _ = execute(state, verb, arg) |
| steps.append({"thought": thought, "action": fmt_action(verb, arg), "result": result}) |
| won = check_success(state) |
| if MODE != "model": |
| time.sleep(0.4) |
| yield render(state, steps, done=won) |
| if won: |
| return |
|
|
| yield render(state, steps, gaveup=not check_success(state)) |
|
|
|
|
| def build_errand(text): |
| t = text.lower() |
| item = next((it for it in ITEMS_KNOWN if it in t), None) |
| place = next((p for p in NODES if p in t and p != "clearing"), None) |
| if item and place: |
| return {"text": text.strip(), "kind": "deliver", "item": item, "dest": place} |
| if item: |
| return {"text": text.strip(), "kind": "fetch", "item": item, "dest": "clearing"} |
| return None |
|
|
|
|
| def on_preset(eid): |
| errand = next(p for p in PRESETS if p["id"] == eid) |
| yield from run(errand) |
|
|
|
|
| def on_free(text): |
| text = (text or "").strip() |
| if not text: |
| yield hint("Pip is waiting for an errand. Pick a quest, or name an item and a place from the wood.") |
| return |
| errand = build_errand(text) |
| if not errand: |
| yield hint("Pip doesn't know that errand. The wood holds a lantern, a silver key, " |
| "honey, a red mushroom, and an acorn. Places are the grove, thicket, brook, " |
| "meadow, hollow, and the old willow.") |
| return |
| yield from run(errand) |
|
|
|
|
| |
| CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Spectral:ital,wght@0,400;0,500;1,400&display=swap'); |
| :root{--paper:#f3ead7;--paper-2:#efe3c9;--ink:#2f2a22;--ink-soft:#6b5a47; |
| --moss:#5a6b3a;--bark:#7c5a36;--line:#c9b48f;--win:#3f6b4a;--lost:#9a5a3a;} |
| .gradio-container,.gradio-container.dark,.dark{ |
| --body-background-fill:transparent;--background-fill-primary:#fffaf0;--background-fill-secondary:#f6edd8; |
| --block-background-fill:#fffaf0;--block-border-color:var(--line);--border-color-primary:var(--line); |
| --body-text-color:var(--ink);--body-text-color-subdued:var(--ink-soft); |
| --block-label-text-color:var(--moss);--block-title-text-color:var(--ink); |
| --block-label-background-fill:#efe3c9;--block-title-background-fill:transparent; |
| --input-background-fill:#fffaf0;--input-border-color:var(--line);--input-placeholder-color:var(--ink-soft); |
| --button-primary-background-fill:var(--moss);--button-primary-background-fill-hover:#48562f; |
| --button-primary-text-color:#fbf7ec;--button-primary-border-color:#48562f; |
| --button-secondary-background-fill:#efe3c9;--button-secondary-background-fill-hover:#e7d6b3; |
| --button-secondary-text-color:var(--ink);--button-secondary-border-color:var(--line); |
| --color-accent:var(--bark);--color-accent-soft:#f3e3c4;} |
| .gradio-container{background:radial-gradient(120% 80% at 80% -10%,#f7efdd,var(--paper) 55%,var(--paper-2)); |
| font-family:'Spectral',Georgia,serif !important;color:var(--ink) !important;max-width:980px !important;} |
| .gradio-container textarea,.gradio-container input[type="text"],.gradio-container input:not([type]){ |
| background:#fffaf0 !important;color:var(--ink) !important;-webkit-text-fill-color:var(--ink) !important;border-color:var(--line) !important;} |
| .gradio-container textarea::placeholder,.gradio-container input::placeholder{color:var(--ink-soft) !important;-webkit-text-fill-color:var(--ink-soft) !important;opacity:1;} |
| .pe-title{font-family:'Fraunces',serif;font-weight:600;font-size:2.5rem;line-height:1;margin:.2rem 0 0;} |
| .pe-title em{font-style:italic;color:var(--moss);} |
| .pe-sub{font-style:italic;color:var(--ink-soft);margin:.35rem 0 1rem;font-size:1.05rem;} |
| .pe-mode{display:inline-block;font-size:.72rem;letter-spacing:.12em;text-transform:uppercase;color:var(--moss);border:1px solid var(--line);border-radius:999px;padding:.15rem .6rem;} |
| .qhead{margin:.2rem 0 .7rem;font-size:1.02rem;} |
| .quest{display:flex;gap:20px;flex-wrap:wrap;align-items:flex-start;} |
| .qside{flex:0 0 340px;max-width:360px;background:#fffaf0;border:1px solid var(--line);border-radius:14px;padding:14px;} |
| .qstate{margin-top:8px;color:var(--ink);font-size:.95rem;line-height:1.5;border-top:1px dashed var(--line);padding-top:8px;} |
| .qlog{flex:1;min-width:260px;display:flex;flex-direction:column;gap:10px;} |
| .step{background:#fffaf0;border:1px solid var(--line);border-left:3px solid var(--moss);border-radius:10px;padding:10px 13px;} |
| .step .th{font-style:italic;color:var(--ink);} |
| .step .ac{display:inline-block;margin:5px 0;font-family:'Fraunces',serif;font-weight:600;font-size:.82rem;letter-spacing:.06em;text-transform:uppercase;color:var(--bark);background:#f3e3c4;border-radius:6px;padding:.1rem .5rem;} |
| .step .re{color:var(--ink-soft);} |
| .status{margin-top:6px;padding:12px 14px;border-radius:10px;font-family:'Fraunces',serif;font-size:1.05rem;} |
| .status.win{background:#e7f0e3;color:var(--win);border:1px solid #b9d4bd;} |
| .status.lost{background:#f3e6dc;color:var(--lost);border:1px solid #e0c4ad;} |
| .qhint{background:#fffaf0;border:1px dashed var(--line);border-radius:12px;padding:18px 20px;color:var(--ink-soft);font-style:italic;} |
| .pe-foot{color:var(--ink-soft);font-size:.82rem;font-style:italic;text-align:center;margin-top:12px;} |
| footer{display:none !important;} |
| """ |
|
|
| _bk = {"title": "Pip's Errand"} |
| if _BLOCKS_HAS_CSS: |
| _bk["css"] = CSS |
| _bk["theme"] = gr.themes.Soft() |
|
|
| with gr.Blocks(**_bk) as demo: |
| public = "gpt-oss-20b · hosted" if MODE == "model" else "Pip's Errand" |
| mode_label = f"{public} · [{MODE}]" if DEBUG else public |
| gr.HTML(f""" |
| <div><div class="pe-title">Pip's <em>Errand</em></div> |
| <div class="pe-sub">Send a familiar into the wood. Watch it think, explore, and deliver.</div> |
| <span class="pe-mode">{mode_label}</span></div>""") |
|
|
| gr.HTML('<div class="qhead" style="color:var(--ink-soft);font-style:italic">' |
| 'Choose an errand, or write your own using the wood\'s items and places.</div>') |
| with gr.Row(): |
| preset_btns = [gr.Button(p["label"], size="sm") for p in PRESETS] |
| with gr.Row(): |
| free = gr.Textbox(placeholder="your own errand... (take the acorn to the meadow)", |
| show_label=False, scale=8, autofocus=True) |
| send = gr.Button("Send Pip", variant="primary", scale=2) |
|
|
| stage = gr.HTML(hint("Pip waits at the clearing, ears pricked, ready for an errand.")) |
| gr.HTML('<div class="pe-foot">Pip sees only the room it stands in. The rest, it must work out for itself.</div>') |
|
|
| for b, p in zip(preset_btns, PRESETS): |
| b.click(on_preset, gr.State(p["id"]), stage) |
| send.click(on_free, free, stage) |
| free.submit(on_free, free, stage) |
|
|
| if __name__ == "__main__": |
| _lk = {} |
| if not _BLOCKS_HAS_CSS: |
| _lk["css"] = CSS |
| _lk["theme"] = gr.themes.Soft() |
| if _LAUNCH_HAS_SSR: |
| _lk["ssr_mode"] = False |
| demo.queue(max_size=24).launch(**_lk) |
|
|