@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.
- package/dist/assistantHost.py +61 -10
- package/dist/connection.js +17 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
127
|
-
"
|
|
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
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
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
|
-
|
|
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
|
package/dist/connection.js
CHANGED
|
@@ -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();
|