@melaya/runner 1.0.77 → 1.0.79

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.
@@ -37,6 +37,10 @@ import traceback
37
37
  _EVENT_PREFIX = "MELASSIST "
38
38
  _IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
39
39
 
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": {}}
43
+
40
44
 
41
45
  def _emit(turn_id: str, kind: str, **fields) -> None:
42
46
  """Write one structured event line to stdout (flushed) for the runner to relay."""
@@ -96,24 +100,45 @@ def _build_agent():
96
100
  model = os.environ.get("MEL_ASSISTANT_MODEL", "") or None
97
101
  language = os.environ.get("MEL_ASSISTANT_LANGUAGE", "en")
98
102
 
99
- # Read-only platform tools (pipelines, runs, costs, usage, templates, evals).
100
- # They POST to /api/v1/private/assistant-tool with MELAYA_API_KEY; tenant
101
- # scope is enforced server-side.
103
+ # Phone control is offered ONLY on the native mobile app surface — the same
104
+ # gate as the server-side melaya_run_phone_task tool. A desktop / web chat
105
+ # never gets the phone_* tools, even if a phone is paired.
106
+ phone_enabled = os.environ.get("MEL_ASSISTANT_SURFACE", "") == "mobile-native"
107
+
108
+ # Read-only platform tools (melaya_agent); phone control (Device Control) on
109
+ # mobile only. These POST to /api/v1/private/assistant-tool + /phone/command
110
+ # with MELAYA_API_KEY; tenant scope is enforced server-side.
111
+ categories = ["melaya_agent"] + (["phone"] if phone_enabled else [])
102
112
  try:
103
- toolkit = build_toolkit(categories=["melaya_agent"])
113
+ toolkit = build_toolkit(categories=categories)
104
114
  except Exception as exc:
105
- _log(f"build_toolkit(melaya_agent) failed: {exc}; starting tool-less")
106
- toolkit = build_toolkit(names=[])
107
-
115
+ _log(f"build_toolkit({categories}) failed: {exc}; retrying melaya_agent only")
116
+ try:
117
+ toolkit = build_toolkit(categories=["melaya_agent"])
118
+ except Exception:
119
+ toolkit = build_toolkit(names=[])
120
+
121
+ phone_rule = (
122
+ "- If the user asks you to DO something on their phone (open an app, browse, "
123
+ "tap, type, comment, post), use the phone_* tools. ALWAYS call "
124
+ "phone_get_screen_tree before you tap or type. Publish comments/posts with "
125
+ "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"
128
+ if phone_enabled else ""
129
+ )
108
130
  sys_prompt = (
109
131
  "You are the Melaya Assistant, the in-app copilot of the Melaya "
110
132
  "agent-orchestration platform. You have READ-ONLY tools over the data "
111
133
  "this user is allowed to see: pipelines, runs, LLM costs, plan usage, "
112
- "validated workflow templates and eval results.\n"
113
- "Rules:\n"
134
+ "validated workflow templates and eval results"
135
+ + (" — plus phone-control tools to drive the user's paired phone." if phone_enabled else ".")
136
+ + "\nRules:\n"
114
137
  "- Use tools to answer questions about the user's pipelines, spend, usage, "
115
- "templates or evals — never invent numbers.\n"
116
- "- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
138
+ "templates or evals — never invent numbers. For cost, melaya_cost_summary "
139
+ "supports dimension='pipeline' to find which pipeline cost the most.\n"
140
+ + phone_rule
141
+ + "- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
117
142
  "- If a question is outside the platform, answer normally without tools.\n"
118
143
  + (f"- Answer in the user's language: {language}.\n" if language and language != "en" else "")
119
144
  )
@@ -129,14 +154,63 @@ def _build_agent():
129
154
  reliability=True,
130
155
  bounded_memory=True,
131
156
  )
157
+ _register_stream_hooks(agent)
132
158
  return agent
133
159
 
134
160
 
161
+ def _register_stream_hooks(agent) -> None:
162
+ """Emit delta / tool events live as the agent produces output (pre_print gives
163
+ cumulative snapshots per message; we forward the new suffix as a delta)."""
164
+ try:
165
+ from shared.runtime.events import _flatten_agent_text
166
+ except Exception:
167
+ return
168
+
169
+ def _pre_print(*_args, **kwargs) -> None:
170
+ try:
171
+ tid = _stream.get("turnId")
172
+ if not tid:
173
+ return
174
+ msg = kwargs.get("msg") or kwargs.get("message")
175
+ if msg is None:
176
+ return
177
+ mid = str(getattr(msg, "id", "") or id(msg))
178
+ text = _flatten_agent_text(getattr(msg, "content", msg))
179
+ if not text:
180
+ return
181
+ prev = _stream["lens"].get(mid, 0)
182
+ if len(text) > prev:
183
+ _emit(tid, "delta", content=text[prev:])
184
+ _stream["lens"][mid] = len(text)
185
+ except Exception:
186
+ pass
187
+
188
+ def _post_acting(*_args, **kwargs) -> None:
189
+ try:
190
+ tid = _stream.get("turnId")
191
+ if not tid:
192
+ return
193
+ for tc in (kwargs.get("tool_calls") or []):
194
+ name = tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
195
+ if name:
196
+ _emit(tid, "tool", name=str(name))
197
+ except Exception:
198
+ pass
199
+
200
+ for hook_type, fn in (("pre_print", _pre_print), ("post_acting", _post_acting)):
201
+ try:
202
+ agent.register_instance_hook(hook_type, fn)
203
+ except Exception:
204
+ pass
205
+
206
+
135
207
  def _run_turn(agent, turn_id: str, message: str) -> None:
136
208
  import asyncio
137
209
  from agentscope.message import Msg
138
210
 
139
211
  _emit(turn_id, "round", n=1)
212
+ _stream["turnId"] = turn_id
213
+ _stream["lens"] = {}
140
214
 
141
215
  async def _go():
142
216
  return await agent(Msg("user", message, "user"))
@@ -145,10 +219,14 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
145
219
  result = asyncio.run(_go())
146
220
  except Exception as exc:
147
221
  _log("turn error:\n" + traceback.format_exc())
222
+ _stream["turnId"] = ""
148
223
  _emit(turn_id, "error", message=str(exc) or "assistant turn failed")
149
224
  _emit(turn_id, "done")
150
225
  return
151
226
 
227
+ _stream["turnId"] = ""
228
+ # Authoritative final answer — the client swaps the streamed plain text for
229
+ # this markdown-rendered version.
152
230
  text = _extract_text(result)
153
231
  _emit(turn_id, "text", content=text)
154
232
  _emit(turn_id, "done")
@@ -825,7 +825,10 @@ export async function connect(opts) {
825
825
  emitEv({ kind: "error", message: "assistantHost.py not found — reinstall @melaya/runner" });
826
826
  return;
827
827
  }
828
- const workDir = join(tmpdir(), `melaya-assistant-${sid}`);
828
+ // Sanitize the session id for a filesystem path — it is
829
+ // `userId:conversationId`, and ':' is illegal in a Windows dir name.
830
+ const safeSid = sid.replace(/[^A-Za-z0-9._-]/g, "_");
831
+ const workDir = join(tmpdir(), `melaya-assistant-${safeSid}`);
829
832
  mkdirSync(workDir, { recursive: true });
830
833
  const stagedHost = join(workDir, "assistantHost.py");
831
834
  copyFileSync(hostScript, stagedHost);
@@ -847,6 +850,7 @@ export async function connect(opts) {
847
850
  MEL_ASSISTANT_PROVIDER: String(payload.provider || "claude_code"),
848
851
  MEL_ASSISTANT_MODEL: String(payload.model || ""),
849
852
  MEL_ASSISTANT_LANGUAGE: String(payload.language || "en"),
853
+ MEL_ASSISTANT_SURFACE: String(payload.surface || ""),
850
854
  // Per-user creds (incl. MELAYA_API_KEY for the tenant-gated tool bridge).
851
855
  ...(payload.credentials || {}),
852
856
  };
@@ -872,18 +876,23 @@ export async function connect(opts) {
872
876
  }
873
877
  }
874
878
  });
879
+ const stderrTail = [];
875
880
  proc.stderr?.on("data", (data) => {
876
- if (!opts.verbose)
877
- return;
878
881
  for (const ln of data.toString().split("\n")) {
879
882
  const t = ln.trimEnd();
880
- if (t)
883
+ if (!t)
884
+ continue;
885
+ if (opts.verbose)
881
886
  console.log(chalk.gray(` [assistant-stderr] ${t}`));
887
+ stderrTail.push(t);
888
+ if (stderrTail.length > 40)
889
+ stderrTail.shift();
882
890
  }
883
891
  });
884
892
  proc.on("exit", (code) => {
885
893
  activeAssistants.delete(sid);
886
- socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "session_closed", code });
894
+ const detail = code !== 0 && stderrTail.length ? stderrTail.slice(-12).join("\n") : "";
895
+ socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "session_closed", code, detail });
887
896
  console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… closed (exit ${code})`));
888
897
  });
889
898
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.77",
3
+ "version": "1.0.79",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,