@melaya/runner 1.0.78 → 1.0.81

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,51 @@ 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
+ "- You are FULLY AUTONOMOUS on the phone: NEVER ask the user a question, "
129
+ "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"
134
+ if phone_enabled else ""
135
+ )
108
136
  sys_prompt = (
109
137
  "You are the Melaya Assistant, the in-app copilot of the Melaya "
110
138
  "agent-orchestration platform. You have READ-ONLY tools over the data "
111
139
  "this user is allowed to see: pipelines, runs, LLM costs, plan usage, "
112
- "validated workflow templates and eval results.\n"
113
- "Rules:\n"
140
+ "validated workflow templates and eval results"
141
+ + (" — plus phone-control tools to drive the user's paired phone." if phone_enabled else ".")
142
+ + "\nRules:\n"
114
143
  "- 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"
144
+ "templates or evals — never invent numbers. For cost, melaya_cost_summary "
145
+ "supports dimension='pipeline' to find which pipeline cost the most.\n"
146
+ + phone_rule
147
+ + "- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
117
148
  "- If a question is outside the platform, answer normally without tools.\n"
118
149
  + (f"- Answer in the user's language: {language}.\n" if language and language != "en" else "")
119
150
  )
@@ -125,18 +156,69 @@ def _build_agent():
125
156
  model_name=model,
126
157
  provider=provider,
127
158
  api_key="", # model.py resolves the provider's own creds (OAuth off disk / localhost)
128
- max_iters=6,
159
+ # Phone tasks are many-step (each comment ≈ screen_tree→tap→type→post), so
160
+ # give them room like the device template (40); read-only Q&A needs few.
161
+ max_iters=40 if phone_enabled else 8,
129
162
  reliability=True,
130
163
  bounded_memory=True,
131
164
  )
165
+ _register_stream_hooks(agent)
132
166
  return agent
133
167
 
134
168
 
169
+ def _register_stream_hooks(agent) -> None:
170
+ """Emit delta / tool events live as the agent produces output (pre_print gives
171
+ cumulative snapshots per message; we forward the new suffix as a delta)."""
172
+ try:
173
+ from shared.runtime.events import _flatten_agent_text
174
+ except Exception:
175
+ return
176
+
177
+ def _pre_print(*_args, **kwargs) -> None:
178
+ try:
179
+ tid = _stream.get("turnId")
180
+ if not tid:
181
+ return
182
+ msg = kwargs.get("msg") or kwargs.get("message")
183
+ if msg is None:
184
+ return
185
+ mid = str(getattr(msg, "id", "") or id(msg))
186
+ text = _flatten_agent_text(getattr(msg, "content", msg))
187
+ if not text:
188
+ return
189
+ prev = _stream["lens"].get(mid, 0)
190
+ if len(text) > prev:
191
+ _emit(tid, "delta", content=text[prev:])
192
+ _stream["lens"][mid] = len(text)
193
+ except Exception:
194
+ pass
195
+
196
+ def _post_acting(*_args, **kwargs) -> None:
197
+ try:
198
+ tid = _stream.get("turnId")
199
+ if not tid:
200
+ return
201
+ for tc in (kwargs.get("tool_calls") or []):
202
+ name = tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
203
+ if name:
204
+ _emit(tid, "tool", name=str(name))
205
+ except Exception:
206
+ pass
207
+
208
+ for hook_type, fn in (("pre_print", _pre_print), ("post_acting", _post_acting)):
209
+ try:
210
+ agent.register_instance_hook(hook_type, fn)
211
+ except Exception:
212
+ pass
213
+
214
+
135
215
  def _run_turn(agent, turn_id: str, message: str) -> None:
136
216
  import asyncio
137
217
  from agentscope.message import Msg
138
218
 
139
219
  _emit(turn_id, "round", n=1)
220
+ _stream["turnId"] = turn_id
221
+ _stream["lens"] = {}
140
222
 
141
223
  async def _go():
142
224
  return await agent(Msg("user", message, "user"))
@@ -145,10 +227,14 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
145
227
  result = asyncio.run(_go())
146
228
  except Exception as exc:
147
229
  _log("turn error:\n" + traceback.format_exc())
230
+ _stream["turnId"] = ""
148
231
  _emit(turn_id, "error", message=str(exc) or "assistant turn failed")
149
232
  _emit(turn_id, "done")
150
233
  return
151
234
 
235
+ _stream["turnId"] = ""
236
+ # Authoritative final answer — the client swaps the streamed plain text for
237
+ # this markdown-rendered version.
152
238
  text = _extract_text(result)
153
239
  _emit(turn_id, "text", content=text)
154
240
  _emit(turn_id, "done")
@@ -370,6 +370,10 @@ export async function connect(opts) {
370
370
  MEL_MODEL_PARAMS_B: String(preflight.profile.paramsBillion ?? ""),
371
371
  MEL_MODEL_MAX_TOKENS: String(preflight.profile.recommendedMaxTokens),
372
372
  MEL_MODEL_MAX_ITERS: String(preflight.profile.recommendedMaxIters),
373
+ // Real context window → sizes BoundedMemory so compression can't
374
+ // evict the task brief on a big local model. Empty when unknown
375
+ // (the agent falls back to a safe per-tier floor).
376
+ MEL_MODEL_CONTEXT_TOKENS: String(preflight.profile.contextTokens ?? ""),
373
377
  MEL_MODEL_DISABLE_THINKING: preflight.profile.thinkingDefault === "off" ? "1" : "0",
374
378
  }
375
379
  : {}),
@@ -714,6 +718,10 @@ export async function connect(opts) {
714
718
  MEL_MODEL_PARAMS_B: String(preflight.profile.paramsBillion ?? ""),
715
719
  MEL_MODEL_MAX_TOKENS: String(preflight.profile.recommendedMaxTokens),
716
720
  MEL_MODEL_MAX_ITERS: String(preflight.profile.recommendedMaxIters),
721
+ // Real context window → sizes BoundedMemory so compression can't
722
+ // evict the task brief on a big local model. Empty when unknown
723
+ // (the agent falls back to a safe per-tier floor).
724
+ MEL_MODEL_CONTEXT_TOKENS: String(preflight.profile.contextTokens ?? ""),
717
725
  MEL_MODEL_DISABLE_THINKING: preflight.profile.thinkingDefault === "off" ? "1" : "0",
718
726
  }
719
727
  : {}),
@@ -850,6 +858,7 @@ export async function connect(opts) {
850
858
  MEL_ASSISTANT_PROVIDER: String(payload.provider || "claude_code"),
851
859
  MEL_ASSISTANT_MODEL: String(payload.model || ""),
852
860
  MEL_ASSISTANT_LANGUAGE: String(payload.language || "en"),
861
+ MEL_ASSISTANT_SURFACE: String(payload.surface || ""),
853
862
  // Per-user creds (incl. MELAYA_API_KEY for the tenant-gated tool bridge).
854
863
  ...(payload.credentials || {}),
855
864
  };
@@ -47,6 +47,7 @@ export interface ModelProfile {
47
47
  thinkingDefault: "on" | "off" | "n/a";
48
48
  recommendedMaxTokens: number;
49
49
  recommendedMaxIters: number;
50
+ contextTokens: number | null;
50
51
  detectedAt: string;
51
52
  reason: string;
52
53
  }
@@ -120,11 +120,12 @@ function _parseParamCount(id) {
120
120
  }
121
121
  function _classify(id, lmstudioMeta) {
122
122
  const lower = id.toLowerCase();
123
+ const ctxTokens = lmstudioMeta?.max_context_length ?? null;
123
124
  // 1. Family override wins if matched.
124
125
  for (const ov of FAMILY_OVERRIDES) {
125
126
  if (ov.pattern.test(lower)) {
126
127
  const params = _parseParamCount(id);
127
- return _buildProfile(id, ov.tier, params, ov.family, ov.thinking, ov.reason);
128
+ return _buildProfile(id, ov.tier, params, ov.family, ov.thinking, ov.reason, ctxTokens);
128
129
  }
129
130
  }
130
131
  // 2. LM Studio metadata if present.
@@ -160,9 +161,9 @@ function _classify(id, lmstudioMeta) {
160
161
  tier = "text-only";
161
162
  reason = `${params}B params < ${TIER_PARAM_THRESHOLDS.agenticMarginal}B threshold — text-only, abort if used for tool-using pipeline`;
162
163
  }
163
- return _buildProfile(id, tier, params, null, "n/a", reason);
164
+ return _buildProfile(id, tier, params, null, "n/a", reason, ctxTokens);
164
165
  }
165
- function _buildProfile(id, tier, paramsBillion, family, thinkingDefault, reason) {
166
+ function _buildProfile(id, tier, paramsBillion, family, thinkingDefault, reason, contextTokens = null) {
166
167
  // Per-tier resource caps. Smaller models get tighter ceilings to
167
168
  // bound runaway thinking loops.
168
169
  const recommendedMaxTokens = tier === "agentic-capable" ? 8192
@@ -190,6 +191,7 @@ function _buildProfile(id, tier, paramsBillion, family, thinkingDefault, reason)
190
191
  thinkingDefault,
191
192
  recommendedMaxTokens,
192
193
  recommendedMaxIters,
194
+ contextTokens: contextTokens && contextTokens > 0 ? contextTokens : null,
193
195
  detectedAt: new Date().toISOString(),
194
196
  reason,
195
197
  };
@@ -233,7 +235,7 @@ export function classifyModel(id, lmstudioMeta) {
233
235
  // Bump whenever FAMILY_OVERRIDES, tier thresholds, or any classifier
234
236
  // logic changes in a way that could re-tier an existing cached model.
235
237
  // Cached profiles with a mismatched version are ignored and re-classified.
236
- const PROFILE_SCHEMA_VERSION = 3;
238
+ const PROFILE_SCHEMA_VERSION = 4;
237
239
  const LMSTUDIO_BASE = process.env.LMSTUDIO_BASE_URL || "http://127.0.0.1:1234";
238
240
  const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434";
239
241
  // JIT-load timeout. 8B-class quantised models on M-series Macs typically
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.78",
3
+ "version": "1.0.81",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,