""" 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")) # ---------------------------------------------------------------- the world ---- 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) # ---------------------------------------------------------------- the brain ---- _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: # noqa: BLE001 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: \n" "ACTION: \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): # a bare direction word return "go", a return "look", "" def parse_step(raw): raw = re.sub(r".*?", "", 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: # model ignored the format; salvage a verb 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: # maybe a bare direction word 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 "") # ---------------------------------------------------------------- the rules ---- 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"] # ------------------------------------------------------ keeper (BFS) solver ---- 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." # ---------------------------------------------------------------- rendering ---- def esc(s): return html.escape(str(s)) def render_map(state): cur, visited = state["loc"], state["visited"] svg = "".join( f'' for a, b in EDGES) for name, (x, y) in NODES.items(): if name == cur: svg += (f'' f'') tc, fw = "var(--ink)", "600" elif name in visited: svg += f'' tc, fw = "var(--ink-soft)", "400" else: svg += f'' tc, fw = "var(--ink-soft)", "400" svg += (f'{esc(pretty(name))}') return f'{svg}' def render(state, steps, done=False, gaveup=False): log = "".join( f'
{esc(s["thought"])}
' f'
{esc(s["action"])}
' f'
{esc(s["result"])}
' for s in steps) inv = ", ".join(state["inv"]) or "nothing" status = "" if done: status = '
The errand is complete. Pip pads home, pleased with itself.
' elif gaveup: status = ('
Pip wanders back, the errand unfinished. ' 'Perhaps another try?
') side = (f'
{render_map(state)}' f'
In the {esc(pretty(state["loc"]))}
' f'Carrying: {esc(inv)}
') head = f'
Errand: {esc(state["errand"]["text"])}
' return f'{head}
{side}
{log}{status}
' def hint(msg): return f'
{esc(msg)}
' def fmt_action(verb, arg): if verb in ("look", "done"): return verb return f"{verb} {arg}".strip() # ---------------------------------------------------------------- the loop ----- 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: # noqa: BLE001 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) # ---------------------------------------------------------------- the look ----- 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"""
Pip's Errand
Send a familiar into the wood. Watch it think, explore, and deliver.
{mode_label}
""") gr.HTML('
' 'Choose an errand, or write your own using the wood\'s items and places.
') 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('
Pip sees only the room it stands in. The rest, it must work out for itself.
') 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)