@melaya/runner 1.0.83 → 1.0.84

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:
@@ -219,9 +223,24 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
219
223
  _emit(turn_id, "round", n=1)
220
224
  _stream["turnId"] = turn_id
221
225
  _stream["lens"] = {}
226
+ _stream["cancel"] = False # fresh turn — clear any stale STOP
222
227
 
228
+ # Run the agent as a cancellable task and poll the STOP flag (flipped by the
229
+ # stdin-reader thread when the user presses STOP in chat). asyncio.cancel()
230
+ # unwinds the agent loop wherever it is awaiting — including mid phone-command
231
+ # HTTP wait — so the run stops promptly instead of finishing the step first.
223
232
  async def _go():
224
- return await agent(Msg("user", message, "user"))
233
+ task = asyncio.ensure_future(agent(Msg("user", message, "user")))
234
+ while not task.done():
235
+ if _stream.get("cancel"):
236
+ task.cancel()
237
+ try:
238
+ await task
239
+ except BaseException: # CancelledError + any teardown error
240
+ pass
241
+ return _CANCELLED
242
+ await asyncio.sleep(0.12)
243
+ return task.result()
225
244
 
226
245
  try:
227
246
  result = asyncio.run(_go())
@@ -232,6 +251,13 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
232
251
  _emit(turn_id, "done")
233
252
  return
234
253
 
254
+ if result is _CANCELLED:
255
+ _stream["turnId"] = ""
256
+ _log("turn cancelled by user STOP")
257
+ _emit(turn_id, "text", content="Stopped.")
258
+ _emit(turn_id, "done")
259
+ return
260
+
235
261
  _stream["turnId"] = ""
236
262
  # Authoritative final answer — the client swaps the streamed plain text for
237
263
  # this markdown-rendered version.
@@ -242,9 +268,26 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
242
268
 
243
269
  def _stdin_reader(q: "queue.Queue[str]") -> None:
244
270
  """Feed stdin lines into a queue so the main loop can apply an idle timeout
245
- (blocking readline can't be interrupted portably)."""
271
+ (blocking readline can't be interrupted portably).
272
+
273
+ STOP is special: a `{"cancel": true}` line must take effect WHILE a turn is
274
+ running, but the main thread is blocked in _run_turn and won't drain the
275
+ queue until it finishes. So handle cancel HERE, on the reader thread — flip
276
+ the shared flag the running turn polls — and never enqueue it as a new turn.
277
+ """
246
278
  try:
247
279
  for line in sys.stdin:
280
+ s = line.strip()
281
+ if s:
282
+ try:
283
+ req = json.loads(s)
284
+ except Exception:
285
+ req = None
286
+ if isinstance(req, dict) and req.get("cancel"):
287
+ tid = str(req.get("turnId") or "")
288
+ if not tid or tid == _stream.get("turnId"):
289
+ _stream["cancel"] = True
290
+ continue # consumed — do not queue as a turn
248
291
  q.put(line)
249
292
  except Exception:
250
293
  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.84",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,