chat: mission mode (/mission, --mission) — autonomous goal runs in background with retries, corrections, state-aware retry hints; input() moved to thread so missions run while the REPL waits; configurable nudge_limit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -82,9 +82,19 @@ you> иди к маяку → look_at + move step by step (auto-conti
|
|||||||
you> повернись налево → turn(-90)
|
you> повернись налево → turn(-90)
|
||||||
you> осмотрись → vision + description
|
you> осмотрись → vision + description
|
||||||
you> активируй маяк → interact (when close)
|
you> активируй маяк → interact (when close)
|
||||||
|
you> /mission → autonomous goal: reach & activate the beacon,
|
||||||
|
keeps trying until done (retries + corrections)
|
||||||
|
you> /mission дойди до маяка
|
||||||
|
you> /status /stop → mission progress / cancel
|
||||||
you> /state /look /map /models /model gemma4:12b /steps N /help /exit
|
you> /state /look /map /models /model gemma4:12b /steps N /help /exit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Missions run in the background while the REPL stays usable — watch the
|
||||||
|
capsule on http://127.0.0.1:8000 as it works. Start one directly:
|
||||||
|
`python -m testbed.chat --mission [--mission-steps 50] [--mission-retries 3]`.
|
||||||
|
The mission keeps trying (corrections when it drifts, nudges when it stalls,
|
||||||
|
fresh attempts on failure) until the goal is achieved; `/stop` cancels it.
|
||||||
|
|
||||||
Each turn's transcript is printed; the current frame lands in `chat_frame.png`
|
Each turn's transcript is printed; the current frame lands in `chat_frame.png`
|
||||||
and the sensor-built map in `chat_map.png`. Any OpenAI-compatible endpoint
|
and the sensor-built map in `chat_map.png`. Any OpenAI-compatible endpoint
|
||||||
works: `python -m testbed.chat --base-url https://api.openai.com/v1
|
works: `python -m testbed.chat --base-url https://api.openai.com/v1
|
||||||
|
|||||||
+179
-9
@@ -44,7 +44,12 @@ You are talking to the capsule's brain (LLM). Type what you want, e.g.:
|
|||||||
'повернись налево' / 'turn left' — quick turn
|
'повернись налево' / 'turn left' — quick turn
|
||||||
'осмотрись' / 'look around' — vision + description
|
'осмотрись' / 'look around' — vision + description
|
||||||
'активируй маяк' / 'activate it' — interact (must be close)
|
'активируй маяк' / 'activate it' — interact (must be close)
|
||||||
Commands: /state /look /map /models /model X /steps N /help /exit"""
|
Missions (autonomous goal, keeps trying until done):
|
||||||
|
/mission — goal: reach and activate the beacon
|
||||||
|
/mission <text> — any goal, e.g. /mission дойди до маяка
|
||||||
|
/status — mission progress + capsule state
|
||||||
|
/stop — cancel the mission (REPL stays usable)
|
||||||
|
Other commands: /state /look /map /models /model X /steps N /help /exit"""
|
||||||
|
|
||||||
|
|
||||||
async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> int:
|
async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> int:
|
||||||
@@ -82,9 +87,10 @@ async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> i
|
|||||||
await asyncio.to_thread(_write)
|
await asyncio.to_thread(_write)
|
||||||
print(f" frame saved to chat_frame.png ({out['width']}x{out['height']})")
|
print(f" frame saved to chat_frame.png ({out['width']}x{out['height']})")
|
||||||
|
|
||||||
def save_map() -> None:
|
def save_map(quiet: bool = False) -> None:
|
||||||
if controller.pos is None:
|
if controller.pos is None:
|
||||||
print(" (no position yet — ask the capsule to move first)")
|
if not quiet:
|
||||||
|
print(" (no position yet — ask the capsule to move first)")
|
||||||
return
|
return
|
||||||
from testbed.room.mapview import draw_sensor_map
|
from testbed.room.mapview import draw_sensor_map
|
||||||
|
|
||||||
@@ -96,18 +102,134 @@ async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> i
|
|||||||
}
|
}
|
||||||
img = draw_sensor_map(layout, controller.pos, controller.yaw, controller.path)
|
img = draw_sensor_map(layout, controller.pos, controller.yaw, controller.path)
|
||||||
img.save("chat_map.png")
|
img.save("chat_map.png")
|
||||||
print(
|
if not quiet:
|
||||||
f" map saved to chat_map.png (pos {controller.pos[0]:.1f}, {controller.pos[1]:.1f})"
|
print(
|
||||||
)
|
f" map saved to chat_map.png (pos {controller.pos[0]:.1f}, {controller.pos[1]:.1f})"
|
||||||
|
)
|
||||||
|
|
||||||
async def cmd_models() -> None:
|
async def cmd_models() -> None:
|
||||||
print(" fetching models…")
|
print(" fetching models…")
|
||||||
for m in ollama_models(args.base_url, args.api_key):
|
for m in ollama_models(args.base_url, args.api_key):
|
||||||
print(f" {m}")
|
print(f" {m}")
|
||||||
|
|
||||||
while True:
|
# -- mission: autonomous goal run in the background ---------------------
|
||||||
|
# The REPL stays responsive: /status to check progress, /stop to cancel.
|
||||||
|
mission: asyncio.Task | None = None
|
||||||
|
mission_goal = "reach the beacon and activate it"
|
||||||
|
|
||||||
|
def mission_log(role: str, msg: str) -> None:
|
||||||
|
print(f" [{role}] {msg}")
|
||||||
|
|
||||||
|
async def map_saver() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
save_map(quiet=True)
|
||||||
|
except Exception as exc: # noqa: BLE001 - never let the saver die
|
||||||
|
print(f" [map] save error: {type(exc).__name__}: {exc}")
|
||||||
|
await asyncio.sleep(3.0)
|
||||||
|
|
||||||
|
async def mission_runner(goal_text: str) -> None:
|
||||||
|
from testbed.llm_agent import run_llm_agent_loop
|
||||||
|
|
||||||
|
controller.messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"MISSION (set by the user): {goal_text}. "
|
||||||
|
"Keep calling tools and do not stop until the goal is achieved. "
|
||||||
|
"Report only when done."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
saver = asyncio.create_task(map_saver())
|
||||||
try:
|
try:
|
||||||
text = input("you> ").strip()
|
for attempt in range(1, args.mission_retries + 2):
|
||||||
|
print(
|
||||||
|
f"[mission] attempt {attempt}/{args.mission_retries + 1}: {goal_text}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
summary = await run_llm_agent_loop(
|
||||||
|
controller,
|
||||||
|
args.mission_steps,
|
||||||
|
log=mission_log,
|
||||||
|
nudge_limit=args.mission_nudges,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f" [mission] LLM error: {exc}")
|
||||||
|
break
|
||||||
|
save_map()
|
||||||
|
if summary["interacted"]:
|
||||||
|
print(f"\n[mission] DONE: {summary['result']}")
|
||||||
|
return
|
||||||
|
print(
|
||||||
|
f" [mission] attempt {attempt} stopped ({summary['result']}, "
|
||||||
|
f"{summary['steps']} steps) — retrying"
|
||||||
|
)
|
||||||
|
controller.messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"You have not achieved the mission yet. "
|
||||||
|
+ (
|
||||||
|
controller.state_hint() + " "
|
||||||
|
if controller.state_hint()
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
+ "Keep trying: call tools and do not stop until the goal is achieved."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[mission] gave up after {args.mission_retries + 1} attempts — "
|
||||||
|
"say '/mission' to retry or command the capsule manually"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
saver.cancel()
|
||||||
|
|
||||||
|
def start_mission(goal_text: str | None = None) -> None:
|
||||||
|
nonlocal mission, mission_goal
|
||||||
|
if mission is not None and not mission.done():
|
||||||
|
print("[mission] already running — /stop first (or wait)")
|
||||||
|
return
|
||||||
|
mission_goal = goal_text or mission_goal
|
||||||
|
mission = asyncio.create_task(mission_runner(mission_goal))
|
||||||
|
|
||||||
|
async def stop_mission() -> None:
|
||||||
|
nonlocal mission
|
||||||
|
if mission is not None and not mission.done():
|
||||||
|
mission.cancel()
|
||||||
|
try:
|
||||||
|
await mission
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
mission = None
|
||||||
|
print("[mission] stopped")
|
||||||
|
else:
|
||||||
|
print(" (no mission running)")
|
||||||
|
|
||||||
|
async def cmd_status() -> None:
|
||||||
|
if mission is not None and not mission.done():
|
||||||
|
print(f"[mission] running: {mission_goal}")
|
||||||
|
else:
|
||||||
|
print(" (no mission running)")
|
||||||
|
await cmd_state()
|
||||||
|
|
||||||
|
if args.mission:
|
||||||
|
start_mission()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Surface a finished mission task so its result is reported once.
|
||||||
|
if mission is not None and mission.done():
|
||||||
|
try:
|
||||||
|
mission.result()
|
||||||
|
except Exception as exc: # noqa: BLE001 - report mission failure
|
||||||
|
print(f"[mission] error: {type(exc).__name__}: {exc}")
|
||||||
|
mission = None
|
||||||
|
print("[mission] finished — you can start another with /mission")
|
||||||
|
try:
|
||||||
|
# input() in a thread: a blocking read here would freeze the
|
||||||
|
# event loop and stall a running mission task.
|
||||||
|
text = (await asyncio.to_thread(input, "you> ")).strip()
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
print("\n[chat] bye")
|
print("\n[chat] bye")
|
||||||
return 0
|
return 0
|
||||||
@@ -115,11 +237,29 @@ async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> i
|
|||||||
continue
|
continue
|
||||||
low = text.lower()
|
low = text.lower()
|
||||||
if low in ("/exit", "exit", "quit", "выход"):
|
if low in ("/exit", "exit", "quit", "выход"):
|
||||||
|
if mission is not None and not mission.done():
|
||||||
|
mission.cancel()
|
||||||
|
try:
|
||||||
|
await mission
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
print("[chat] bye")
|
print("[chat] bye")
|
||||||
return 0
|
return 0
|
||||||
if low == "/help":
|
if low == "/help":
|
||||||
print(HELP)
|
print(HELP)
|
||||||
continue
|
continue
|
||||||
|
if low == "/mission":
|
||||||
|
start_mission()
|
||||||
|
continue
|
||||||
|
if low.startswith("/mission "):
|
||||||
|
start_mission(low.split(maxsplit=1)[1])
|
||||||
|
continue
|
||||||
|
if low == "/stop":
|
||||||
|
await stop_mission()
|
||||||
|
continue
|
||||||
|
if low == "/status":
|
||||||
|
await cmd_status()
|
||||||
|
continue
|
||||||
if low == "/state":
|
if low == "/state":
|
||||||
await cmd_state()
|
await cmd_state()
|
||||||
continue
|
continue
|
||||||
@@ -194,6 +334,7 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
"""Run the chat session, reconnecting to the bridge if the connection dies."""
|
"""Run the chat session, reconnecting to the bridge if the connection dies."""
|
||||||
attempts = 0
|
attempts = 0
|
||||||
while True:
|
while True:
|
||||||
|
session_rc: int | None = None
|
||||||
try:
|
try:
|
||||||
transport = WebSocketClientTransport(args.url)
|
transport = WebSocketClientTransport(args.url)
|
||||||
async with AICCClient(transport) as client:
|
async with AICCClient(transport) as client:
|
||||||
@@ -202,12 +343,16 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
f"[handshake] session {manifest.session_id} world {manifest.world.name}"
|
f"[handshake] session {manifest.session_id} world {manifest.world.name}"
|
||||||
)
|
)
|
||||||
print(f"[handshake] tools: {[t.id for t in manifest.tools]}")
|
print(f"[handshake] tools: {[t.id for t in manifest.tools]}")
|
||||||
return await chat_loop(client, manifest, args)
|
session_rc = await chat_loop(client, manifest, args)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
raise
|
raise
|
||||||
except Exception as exc: # noqa: BLE001 - connection lost: reconnect
|
except Exception as exc: # noqa: BLE001 - connection lost: reconnect
|
||||||
|
if session_rc is not None:
|
||||||
|
# chat_loop exited cleanly; only cleanup failed — ignore.
|
||||||
|
print(f" (cleanup note: {type(exc).__name__})")
|
||||||
|
return session_rc
|
||||||
attempts += 1
|
attempts += 1
|
||||||
print(f"\n[chat] connection lost ({type(exc).__name__}: {exc})")
|
print(f"\n[chat] connection lost ({type(exc).__name__}: {exc})")
|
||||||
if attempts >= 3:
|
if attempts >= 3:
|
||||||
@@ -219,6 +364,8 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
"[chat] reconnecting in 2 seconds… (the room keeps its state on the bridge)"
|
"[chat] reconnecting in 2 seconds… (the room keeps its state on the bridge)"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(2.0)
|
await asyncio.sleep(2.0)
|
||||||
|
else:
|
||||||
|
return session_rc if session_rc is not None else 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -243,6 +390,29 @@ def main() -> int:
|
|||||||
default=8,
|
default=8,
|
||||||
help="how many tool steps the model may chain per request (0 = one action per turn)",
|
help="how many tool steps the model may chain per request (0 = one action per turn)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mission",
|
||||||
|
action="store_true",
|
||||||
|
help="start an autonomous mission on connect: reach and activate the beacon",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mission-steps",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="tool steps per mission attempt (default 50)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mission-retries",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="restarts after a failed attempt (default 2)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mission-nudges",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="how many consecutive nudges a mission may use before giving up (default 3)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
return asyncio.run(run(args))
|
return asyncio.run(run(args))
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ async def run_llm_agent_loop(
|
|||||||
*,
|
*,
|
||||||
log: LogFn,
|
log: LogFn,
|
||||||
recorder: Any | None = None,
|
recorder: Any | None = None,
|
||||||
|
nudge_limit: int = 1,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the controller until the mission is done or steps run out."""
|
"""Drive the controller until the mission is done or steps run out."""
|
||||||
summary: dict[str, Any] = {
|
summary: dict[str, Any] = {
|
||||||
@@ -450,11 +451,13 @@ async def run_llm_agent_loop(
|
|||||||
if not turn.calls:
|
if not turn.calls:
|
||||||
# Nudge a model that drifted into plain text back to acting.
|
# Nudge a model that drifted into plain text back to acting.
|
||||||
silent = (not turn.text) or len(turn.text) < 8
|
silent = (not turn.text) or len(turn.text) < 8
|
||||||
already_nudged = (
|
consecutive_nudges = 0
|
||||||
bool(controller.messages)
|
for msg in reversed(controller.messages):
|
||||||
and controller.messages[-1].get("content") == NUDGE
|
if msg.get("content") == NUDGE:
|
||||||
)
|
consecutive_nudges += 1
|
||||||
if silent or already_nudged:
|
else:
|
||||||
|
break
|
||||||
|
if silent or consecutive_nudges >= nudge_limit:
|
||||||
summary["result"] = "model produced no tool call"
|
summary["result"] = "model produced no tool call"
|
||||||
return summary
|
return summary
|
||||||
controller.messages.append({"role": "user", "content": NUDGE})
|
controller.messages.append({"role": "user", "content": NUDGE})
|
||||||
|
|||||||
Reference in New Issue
Block a user