Pip-Errand / app.py
kobinasam's picture
initial-commit
c3ead3b verified
Raw
History Blame Contribute Delete
21.8 kB
"""
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: <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): # a bare direction word
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: # 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'<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()
# ---------------------------------------------------------------- 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"""
<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)