@hncode/hncode 0.5.2 → 0.5.4

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/src/llm.js CHANGED
@@ -93,7 +93,7 @@ export function toOpenAi(messages) {
93
93
  } else if (m.role === 'assistant') {
94
94
  const hasText = typeof m.content === 'string' && m.content.trim();
95
95
  const hasCalls = Array.isArray(m.toolCalls) && m.toolCalls.length;
96
- if (!hasText && !hasCalls) continue; // 上游不允许空 assistant,直接丢弃
96
+ if (!hasText && !hasCalls) continue; // the provider rejects an empty assistant turn; drop it
97
97
  const msg = { role: 'assistant', content: m.content || null };
98
98
  if (hasCalls) {
99
99
  msg.tool_calls = m.toolCalls.map((tc) => ({
@@ -296,7 +296,9 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
296
296
  model: cfg.innerModel, messages: toOpenAi(messages),
297
297
  ...common, ...wire,
298
298
  };
299
- if (!opts.noTools) body.tools = defs;
299
+ // Only attach a NON-EMPTY tools array: `tools: []` is rejected by some strict
300
+ // gateways, and an instance that registered no list means "no tools here".
301
+ if (!opts.noTools && defs.length) body.tools = defs;
300
302
  return JSON.stringify(applyPromptCache('openai', body, {
301
303
  enabled: cfg.promptCache !== false,
302
304
  sessionKey: cfg.sessionId,
@@ -313,6 +315,12 @@ export class LLM {
313
315
  constructor(cfg) {
314
316
  this.cfg = cfg;
315
317
  this.controller = null;
318
+ // The tool defs THIS instance sends. Per-instance, NOT module-global: the
319
+ // global (`llmToolsList` below) was overwritten by every `new Agent()` in
320
+ // the process, so a subagent, swarm member or teammate constructed while a
321
+ // parent request was building its body silently swapped the parent's tool
322
+ // list mid-turn. The agent sets this via setTools() right after construction.
323
+ this._tools = null;
316
324
  // Inline-reasoning state carried ACROSS requests. Our system prompt tells the
317
325
  // model to put all its reasoning inside one <think> span, and tool calls happen
318
326
  // inside that span — so the span outlives a single request. Reset when the turn
@@ -321,6 +329,11 @@ export class LLM {
321
329
  this._tagPending = '';
322
330
  }
323
331
 
332
+ // Register the tool list this instance advertises (see `_tools`). Only ever
333
+ // store an array or null: a non-array would make every later request throw
334
+ // when the defs are built.
335
+ setTools(list) { this._tools = Array.isArray(list) ? list : null; }
336
+
324
337
  // Called by the agent at the end of a turn: the next turn starts a fresh
325
338
  // reasoning block, even if the previous one never saw its closing tag.
326
339
  resetThink() {
@@ -341,13 +354,29 @@ export class LLM {
341
354
  // a capped budget plus a reasoning model is what silently produced an empty
342
355
  // title (see buildBody), and an internal request has no tools to call.
343
356
  async requestText(messages, opts = {}) {
357
+ // One silent retry on FAILURE (null). These one-shot requests (title,
358
+ // compaction summary) have no outer retry loop, and a single transient
359
+ // 429/network blip used to come back as null — which the compaction path
360
+ // treated as "no summary" and trimmed the history over. `''` (capped-empty)
361
+ // is a meaningful verdict for the caller and is NOT retried here.
362
+ let out = await this._requestTextOnce(messages, opts);
363
+ if (out === null && !(this.controller && this.controller.signal.aborted)) {
364
+ await new Promise((r) => setTimeout(r, 400));
365
+ out = await this._requestTextOnce(messages, opts);
366
+ }
367
+ return out;
368
+ }
369
+
370
+ async _requestTextOnce(messages, opts = {}) {
344
371
  const cfg = this.cfg;
345
372
  this.controller = new AbortController();
373
+ // This instance's tools, unless the caller opted out (internal one-shots).
374
+ const bodyOpts = { ...opts, ...((this._tools && !opts.noTools) ? { tools: this._tools } : {}) };
346
375
  try {
347
376
  const res = await fetch(cfg.endpoint, {
348
377
  method: 'POST',
349
378
  headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
350
- body: (() => { let b = buildBody(cfg, messages, false, opts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
379
+ body: (() => { let b = buildBody(cfg, messages, false, bodyOpts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
351
380
  signal: this.controller.signal,
352
381
  });
353
382
  if (!res || !res.ok) {
@@ -397,7 +426,7 @@ export class LLM {
397
426
  }
398
427
  }
399
428
 
400
- async request(messages, onEvent) {
429
+ async request(messages, onEvent, cacheEnd) {
401
430
  // Mixin seam: let plugins mutate the messages sent to the model.
402
431
  try { const out = await runPatch('llmRequest', { messages, cfg: this.cfg }, (c) => c); if (out && out.messages) messages = out.messages; } catch (e) { /* patch failed; proceed with original */ }
403
432
  const cfg = this.cfg;
@@ -418,6 +447,11 @@ export class LLM {
418
447
  // turns it off and the retry goes out without it — degrading to an estimate
419
448
  // rather than failing the request over an accounting nicety.
420
449
  const reqOpts = {};
450
+ // This instance's tools ride along on every attempt (per-instance; see the
451
+ // constructor note on `_tools`). `tools` is only included when set, so
452
+ // buildBody's hasOwnProperty probe falls through to the global for callers
453
+ // that never registered a list.
454
+ const instanceTools = this._tools ? { tools: this._tools } : {};
421
455
 
422
456
  for (let attempt = 1; attempt <= RETRIES; attempt++) {
423
457
  attempts = attempt;
@@ -435,7 +469,7 @@ export class LLM {
435
469
  res = await fetch(cfg.endpoint, {
436
470
  method: 'POST',
437
471
  headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
438
- body: (() => { let b = buildBody(cfg, messages, true, reqOpts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
472
+ body: (() => { let b = buildBody(cfg, messages, true, { ...reqOpts, cacheEnd, ...instanceTools }); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
439
473
  signal,
440
474
  });
441
475
  } catch (e) {
@@ -452,6 +486,10 @@ export class LLM {
452
486
  if (res.status === 400 && reqOpts.includeUsage !== false) reqOpts.includeUsage = false;
453
487
  }
454
488
  if (attempt < RETRIES) {
489
+ // Release the FAILED attempt's body so its socket returns to the pool.
490
+ // An unread body pins the connection until GC — under a sustained
491
+ // 429/5xx storm the retries accumulated one open connection per attempt.
492
+ if (res && res.body) { try { await res.body.cancel(); } catch { /* already gone */ } }
455
493
  // Small exponential backoff (200ms, 400ms). If the user hit Esc during
456
494
  // the wait, stop retrying.
457
495
  const tryingSignal = this.controller.signal;
@@ -475,7 +513,17 @@ export class LLM {
475
513
  return;
476
514
  }
477
515
  if (cfg.stream === false) {
478
- const json = await res.json();
516
+ // A 200 with a malformed/empty body used to reject here, and the caller
517
+ // (an unawaited `runAgent`) turned that rejection into a process exit via
518
+ // the global unhandledRejection handler. Surface it as an error EVENT —
519
+ // the same channel every other failure on this path uses.
520
+ let json;
521
+ try {
522
+ json = await res.json();
523
+ } catch (e) {
524
+ onEvent({ type: 'error', error: new Error(`LLM returned a malformed non-streaming body: ${e && e.message}`) });
525
+ return;
526
+ }
479
527
  return consumeNonStreaming(cfg.protocol, json, onEvent);
480
528
  }
481
529
  if (!res.body) { onEvent({ type: 'end' }); return; }