@melaya/runner 1.0.83 → 1.0.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,8 +38,12 @@ _EVENT_PREFIX = "MELASSIST "
38
38
  _IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
39
39
 
40
40
  # Streaming state for the CURRENT turn — the pre_print / post_acting hooks read
41
- # this to emit delta / tool events keyed to the turn in flight.
42
- _stream = {"turnId": "", "lens": {}}
41
+ # this to emit delta / tool events keyed to the turn in flight. `cancel` is the
42
+ # STOP flag: set by the stdin-reader thread, polled by the running turn.
43
+ _stream = {"turnId": "", "lens": {}, "cancel": False}
44
+
45
+ # Sentinel returned by a turn's coroutine when the user pressed STOP.
46
+ _CANCELLED = object()
43
47
 
44
48
 
45
49
  def _emit(turn_id: str, kind: str, **fields) -> None:
@@ -123,14 +127,22 @@ def _build_agent():
123
127
  "tap, type, comment, post), use the phone_* tools. ALWAYS call "
124
128
  "phone_get_screen_tree before you tap or type. Publish comments/posts with "
125
129
  "phone_post_comment / phone_create_post — every publish is approved by the "
126
- "user ON THEIR PHONE, so never refuse for safety. Only touch apps the user "
127
- "asked for; the phone enforces an allowlist and blocks the rest.\n"
130
+ "user ON THEIR PHONE, so never refuse for safety.\n"
131
+ "- EXACT APP ONLY this overrides your autonomy: operate on the EXACT app "
132
+ "the user named, never a different one. If that app is not in the allowlist "
133
+ "(phone_open_app returns app_not_allowed) or not installed, STOP and tell the "
134
+ "user to authorize/install it in Device Control. NEVER substitute a 'similar', "
135
+ "'closest', or 'equivalent' app (e.g. do NOT open Instagram when asked for "
136
+ "TikTok). Opening the wrong app is a FAILED task, not a reasonable default. "
137
+ "After the user authorizes the app, phone_list_apps reflects it immediately — "
138
+ "re-check and open the REQUESTED app.\n"
128
139
  "- You are FULLY AUTONOMOUS on the phone: NEVER ask the user a question, "
129
140
  "never end your turn asking for confirmation or offering a choice — make the "
130
- "reasonable decision and DO it. If a feed is algorithmic, use the app's own "
131
- "Search / Explore to find the right content yourself. Keep going until the "
132
- "task is COMPLETE (e.g. all N comments posted) or you are genuinely blocked; "
133
- "only then report what you did and what (if anything) is blocked.\n"
141
+ "reasonable decision and DO it (the ONE exception is the EXACT-APP rule above: "
142
+ "a missing app is a stop, not a substitution). If a feed is algorithmic, use "
143
+ "the app's own Search / Explore to find the right content yourself. Keep going "
144
+ "until the task is COMPLETE (e.g. all N comments posted) or you are genuinely "
145
+ "blocked; only then report what you did and what (if anything) is blocked.\n"
134
146
  if phone_enabled else ""
135
147
  )
136
148
  sys_prompt = (
@@ -219,9 +231,24 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
219
231
  _emit(turn_id, "round", n=1)
220
232
  _stream["turnId"] = turn_id
221
233
  _stream["lens"] = {}
234
+ _stream["cancel"] = False # fresh turn — clear any stale STOP
222
235
 
236
+ # Run the agent as a cancellable task and poll the STOP flag (flipped by the
237
+ # stdin-reader thread when the user presses STOP in chat). asyncio.cancel()
238
+ # unwinds the agent loop wherever it is awaiting — including mid phone-command
239
+ # HTTP wait — so the run stops promptly instead of finishing the step first.
223
240
  async def _go():
224
- return await agent(Msg("user", message, "user"))
241
+ task = asyncio.ensure_future(agent(Msg("user", message, "user")))
242
+ while not task.done():
243
+ if _stream.get("cancel"):
244
+ task.cancel()
245
+ try:
246
+ await task
247
+ except BaseException: # CancelledError + any teardown error
248
+ pass
249
+ return _CANCELLED
250
+ await asyncio.sleep(0.12)
251
+ return task.result()
225
252
 
226
253
  try:
227
254
  result = asyncio.run(_go())
@@ -232,6 +259,13 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
232
259
  _emit(turn_id, "done")
233
260
  return
234
261
 
262
+ if result is _CANCELLED:
263
+ _stream["turnId"] = ""
264
+ _log("turn cancelled by user STOP")
265
+ _emit(turn_id, "text", content="Stopped.")
266
+ _emit(turn_id, "done")
267
+ return
268
+
235
269
  _stream["turnId"] = ""
236
270
  # Authoritative final answer — the client swaps the streamed plain text for
237
271
  # this markdown-rendered version.
@@ -242,9 +276,26 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
242
276
 
243
277
  def _stdin_reader(q: "queue.Queue[str]") -> None:
244
278
  """Feed stdin lines into a queue so the main loop can apply an idle timeout
245
- (blocking readline can't be interrupted portably)."""
279
+ (blocking readline can't be interrupted portably).
280
+
281
+ STOP is special: a `{"cancel": true}` line must take effect WHILE a turn is
282
+ running, but the main thread is blocked in _run_turn and won't drain the
283
+ queue until it finishes. So handle cancel HERE, on the reader thread — flip
284
+ the shared flag the running turn polls — and never enqueue it as a new turn.
285
+ """
246
286
  try:
247
287
  for line in sys.stdin:
288
+ s = line.strip()
289
+ if s:
290
+ try:
291
+ req = json.loads(s)
292
+ except Exception:
293
+ req = None
294
+ if isinstance(req, dict) and req.get("cancel"):
295
+ tid = str(req.get("turnId") or "")
296
+ if not tid or tid == _stream.get("turnId"):
297
+ _stream["cancel"] = True
298
+ continue # consumed — do not queue as a turn
248
299
  q.put(line)
249
300
  except Exception:
250
301
  pass
@@ -966,6 +966,23 @@ export async function connect(opts) {
966
966
  activeAssistants.delete(String(payload.sessionId));
967
967
  }
968
968
  });
969
+ // Cooperative cancel: the user pressed STOP in chat. Write a cancel line to the
970
+ // host's stdin — its stdin reader thread flips a flag the running turn's
971
+ // hooks poll, so the agent loop aborts BETWEEN steps (right where the next
972
+ // phone action would fire) WITHOUT killing the warm session. The server-side
973
+ // phone:kill gate independently halts any in-flight phone action, so even a
974
+ // host that predates this line stops driving the phone; this just also stops
975
+ // it burning model tokens and keeps the session warm for the next message.
976
+ socket.on("runner:assistant_cancel", (payload) => {
977
+ const s = activeAssistants.get(String(payload.sessionId || ""));
978
+ if (!s)
979
+ return;
980
+ s.lastActivity = Date.now();
981
+ try {
982
+ s.proc.stdin?.write(JSON.stringify({ turnId: payload.turnId, cancel: true }) + "\n");
983
+ }
984
+ catch { /* best-effort */ }
985
+ });
969
986
  // Idle sweep — reap assistant hosts with no turn in ASSISTANT_IDLE_MS.
970
987
  setInterval(() => {
971
988
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.83",
3
+ "version": "1.0.85",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,