koneck 2.95.0 → 2.97.0

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/engine.js CHANGED
@@ -7,7 +7,7 @@ import { execa } from 'execa';
7
7
  import { TOOL_DEFINITIONS, dispatchTool, describeShell, unknownToolError, resolveToolAlias, aliasNotice, isToolFailure, } from './tools.js';
8
8
  import { tui } from './tui.js';
9
9
  import { hasWorkspaceOwnedGitRepo, gitScope, createWorktree, mergeWorktree, preservedWorktreeNotice } from './worktree.js';
10
- import { resolveProvider, getApiKey, keyProvenance, servesLocally, servesOwnWeights, needsNoCredential, isPrivateHost } from './providers.js';
10
+ import { resolveProvider, getApiKey, keyProvenance, servesLocally, servesOwnWeights, needsNoCredential } from './providers.js';
11
11
  import { wireEffort, looksLikeEffortUnsupported, acceptedEfforts, nearestAccepted } from './pace.js';
12
12
  import { triggerFor, pickNext, describeSwitch, askSwitch, keyOf as failoverKey } from './failover.js';
13
13
  import { findChurn, describeChurn, WINDOW as CHURN_WINDOW } from './cycle.js';
@@ -17,7 +17,7 @@ import { handoffFrom, describeHandoff, worthSaying } from './handoff.js';
17
17
  import { readAll as readLedger, relevant as relevantFindings, withStaleness, describeFindings, deriveFindings, record as recordFindings, changedSince, currentSha } from './ledger.js';
18
18
  import { repoNameFor } from './session-bus.js';
19
19
  import { compressIfNeeded } from './compressor.js';
20
- import { contextLimitFor, looksLikeContextOverflow, parseContextLimit, fetchModelContextLimit, ollamaServingLimit, fixedRequestTokens, overheadIsCrowded, crowdedWindowAdvice, probeOllamaContext, detectWindowPlateau, truncationNotice, } from './context-limit.js';
20
+ import { contextLimitFor, looksLikeContextOverflow, parseContextLimit, fetchModelContextLimit, ollamaServingLimit, fixedRequestTokens, overheadIsCrowded, crowdedWindowAdvice, probeOllamaContext, detectWindowPlateau, truncationNotice, ollamaWindowFix, runsOnThisMachine, } from './context-limit.js';
21
21
  import { generateSessionId, saveSession } from './session.js';
22
22
  import { EMPTY_PLAN, parsePlan, planReminder, planAck, noPlanReminder } from './plan.js';
23
23
  import { spillIfOversized } from './spill.js';
@@ -26,6 +26,7 @@ import { resolveSandbox, describeSandbox } from './sandbox.js';
26
26
  import { loadImages, buildUserContent, imageMime, looksLikeVisionUnsupported, visionUnsupportedNotice, } from './images.js';
27
27
  import { parseTextToolCalls, paramTypesFrom, looksLikeTextToolCall, stripTemplateTokens, splitEmittable, parseCommandStyleCalls, toolShapesFrom } from './tool-call-text.js';
28
28
  import { isIdentityQuestion, identityAnswer } from './identity.js';
29
+ import { deniedCapabilities, capabilityCorrection, capabilityNotice } from './capability-claims.js';
29
30
  import { leanTools, leanModeNotice, findToolsSchema, matchDeferred, deferredToolNotice, FIND_TOOLS } from './lean-mode.js';
30
31
  import { findTailRepetition, trimTailRepetition, repetitionNotice, CHECK_EVERY } from './repetition.js';
31
32
  import { loadMemory } from './memory.js';
@@ -658,7 +659,14 @@ export function truncateMemory(memory, limit = MAX_MEMORY_CHARS) {
658
659
  const from = kept.indexOf('\n');
659
660
  return '(earlier notes omitted)\n' + (from === -1 ? kept : kept.slice(from + 1));
660
661
  }
661
- function buildSystemPrompt(ctx, memory = null, project = null, agentInstructions = null,
662
+ /*
663
+ * Exported so what the model is told about its own identity can be asserted.
664
+ *
665
+ * That block is the whole reason identity.ts exists — a model told nothing about who wrote the
666
+ * program it is running reaches for whichever lab trained it — and it is a string in the middle of
667
+ * a large function, which is exactly the kind of thing that gets edited back to something wrong.
668
+ */
669
+ export function buildSystemPrompt(ctx, memory = null, project = null, agentInstructions = null,
662
670
  /**
663
671
  * Whether the window is too small for guidance.
664
672
  *
@@ -692,8 +700,10 @@ personality = 'default') {
692
700
  const rules = `You are KONECK, a production-grade autonomous coding agent.
693
701
 
694
702
  WHO YOU ARE — fact, not inference:
695
- KONECK is Kona Dludlu's open-source coding agent, written in Eswatini.
696
- https://github.com/Gubevu/konech
703
+ KONECK is an independent open-source coding agent. Who wrote it is deliberately not disclosed: you
704
+ do not know, and there is nothing to work out. If you are asked, say it is not disclosed. Never
705
+ name a person or a company as the author, and never guess — an omission is not an invitation to
706
+ fill the gap.
697
707
  You are running on the model \`${ctx.model ?? 'unnamed'}\` served by \`${ctx.provider ?? 'unknown'}\`.
698
708
  KONECK is the program; the model is a part it uses. You were NOT made by Anthropic, OpenAI,
699
709
  Google, Alibaba or DeepSeek — the model's maker is not yours, and you must not claim to be the
@@ -876,6 +886,15 @@ export async function mapWithLimit(items, limit, fn) {
876
886
  return results;
877
887
  }
878
888
  async function handleSpawnAgents(args, config, cwd,
889
+ /*
890
+ * The parent's stop, so sub-agents go down with it.
891
+ *
892
+ * They were started with no signal at all, so stopping a run that had fanned out to six agents
893
+ * stopped the parent and left six agents working — each with its own turns, its own tools and its
894
+ * own commands, none of which anybody could reach. That is the worst version of the stop problem
895
+ * rather than a corner of it: the visible run ends and the machine keeps going.
896
+ */
897
+ abortSignal,
879
898
  /**
880
899
  * The parent's counters, so delegated work is counted as work.
881
900
  *
@@ -919,6 +938,20 @@ delegated) {
919
938
  // trip a per-second limit even when three concurrent is otherwise sustainable.
920
939
  if (i > 0)
921
940
  await new Promise(r => setTimeout(r, (i % concurrency) * AGENT_STAGGER_MS));
941
+ /*
942
+ * Nothing new is started after the stop.
943
+ *
944
+ * With a concurrency of three and nine tasks, the last six are queued behind the first three —
945
+ * so without this, stopping would be followed by six fresh agents starting up, each of which
946
+ * then has to be stopped in turn. A worktree is created here too, and one made for an agent
947
+ * that is not going to run is a directory somebody has to clean up.
948
+ */
949
+ if (abortSignal?.aborted) {
950
+ mine.status = 'failed';
951
+ publish();
952
+ return { task, success: false,
953
+ output: 'NOT RUN: stopped at the user\'s request before this agent started.' };
954
+ }
922
955
  let taskCwd = cwd;
923
956
  let branch;
924
957
  if (isGit && args.use_worktrees !== false) {
@@ -973,7 +1006,7 @@ delegated) {
973
1006
  };
974
1007
  try {
975
1008
  const session = await createAgentSession(subConfig);
976
- await session.send(task);
1009
+ await session.send(task, abortSignal);
977
1010
  // The last assistant message with something in it — not merely the last, which after a
978
1011
  // tool call is often an empty shell — and enough of it to be worth having. Three hundred
979
1012
  // characters cut a sub-agent's conclusions mid-sentence, so the parent had to work out
@@ -1100,7 +1133,17 @@ export function toolDetailFor(name, argsJson) {
1100
1133
  * before a conversation starts, so on the default there is almost no room to work in, and the
1101
1134
  * symptom is exactly this: nothing comes back.
1102
1135
  */
1103
- export function emptyResponseAdvice(provider, model, count, toolsWorked, endpoint) {
1136
+ export function emptyResponseAdvice(provider, model, count, toolsWorked, endpoint,
1137
+ /*
1138
+ * What this run's instructions and tool descriptions actually cost.
1139
+ *
1140
+ * This was the string "about 3.8k", with a comment four lines above it saying 3,600 — two
1141
+ * different figures for the same thing in the same file, neither of them measured. The real
1142
+ * number moves with the tools a run has: lean mode drops some, an MCP server adds more, and a
1143
+ * session with skills loaded is larger again. Quoting a constant is asserting something not
1144
+ * known, and it is the number somebody uses to decide what to set the window to.
1145
+ */
1146
+ fixedTokens, platform) {
1104
1147
  // The endpoint is named, not just the provider slot. Reported as "omni returned 3 empty
1105
1148
  // responses ... yet i am using ollama" — both true at once, because the endpoint saved under the
1106
1149
  // omni slot is an Ollama box. Naming the address answers the objection in the message itself.
@@ -1123,13 +1166,19 @@ export function emptyResponseAdvice(provider, model, count, toolsWorked, endpoin
1123
1166
  */
1124
1167
  const runtime = servesOwnWeights(provider);
1125
1168
  const ownNetwork = endpoint ? servesLocally(provider, endpoint) : provider.toLowerCase() === 'ollama';
1169
+ const remote = endpoint ? !runsOnThisMachine(endpoint) : false;
1170
+ const cost = fixedTokens && fixedTokens > 0
1171
+ ? `${fixedTokens.toLocaleString()} tokens of that — measured for this run, with the tools it `
1172
+ + `actually has —`
1173
+ : 'most of that';
1126
1174
  const ollamaFix = 'Ollama serves a default of 4,096 tokens whatever the model was trained for, and silently ' +
1127
1175
  'truncates a longer prompt instead of refusing it — and KONECK\'s instructions and tool ' +
1128
- 'descriptions come to about 3.8k of that before your conversation is counted, which is exactly ' +
1129
- 'how a turn comes back with nothing in it. Set OLLAMA_CONTEXT_LENGTH=32768 where Ollama runs' +
1130
- (endpoint ? ` the machine serving ${endpoint}` : '') +
1131
- ', or use a Modelfile with PARAMETER num_ctx 32768, and restart it. If you know the real ' +
1132
- 'limit, `/config contextTokens <n>` makes KONECK compact before reaching it.';
1176
+ `descriptions come to ${cost} before your conversation is counted, which is exactly ` +
1177
+ 'how a turn comes back with nothing in it.\n\n' +
1178
+ ollamaWindowFix({ ...(endpoint !== undefined ? { endpoint } : {}), remote,
1179
+ ...(platform !== undefined ? { platform } : {}) }) +
1180
+ '\n\nIf you know the real limit, `/config contextTokens <n>` makes KONECK compact before ' +
1181
+ 'reaching it.';
1133
1182
  if (runtime) {
1134
1183
  return observed + '\nThe likeliest cause is the context window rather than the model. ' + ollamaFix;
1135
1184
  }
@@ -1485,12 +1534,16 @@ detectedContext = { current: 0 }) {
1485
1534
  let textToolsNoticed = false;
1486
1535
  /** Said once: a model that writes calls as shell commands will keep doing it. */
1487
1536
  let commandStyleNoticed = false;
1537
+ /** One correction per user message: a model that repeats a false denial will not be argued out. */
1538
+ let capabilityCorrected = false;
1488
1539
  // The tool list, cut to fit when the window cannot hold it.
1489
1540
  //
1490
1541
  // Nine of the twenty-three tools are git subcommands, about 550 tokens between them, and every
1491
1542
  // one is a line of shell that execute_command already runs. Reported as a session that planned
1492
1543
  // three steps, called `plan`, and then returned three empty turns: there was nothing left of the
1493
1544
  // window to answer in.
1545
+ /** What this run costs before the conversation is counted. Set just below, read at the plateau. */
1546
+ let fixedCost = 0;
1494
1547
  // What every request costs before the user has typed anything — the instructions plus the tool
1495
1548
  // descriptions. On a large window this is 12% of it and there is nothing to discuss. On a small
1496
1549
  // one it was 93%, which is why a model looped, wrote tool calls as prose, and answered questions
@@ -1518,6 +1571,14 @@ detectedContext = { current: 0 }) {
1518
1571
  fixed = fixedRequestTokens(systemChars, JSON.stringify(openaiTools));
1519
1572
  }
1520
1573
  }
1574
+ /*
1575
+ * Recorded after lean mode has had its go, so it is the floor that actually applies.
1576
+ *
1577
+ * A window discovered mid-stream is compared against this. Before lean mode it would overstate
1578
+ * the floor, and telling somebody their window cannot hold a prompt that has since been
1579
+ * trimmed to fit would be the wrong answer confidently given.
1580
+ */
1581
+ fixedCost = fixed;
1521
1582
  // Said only if it still does not fit after that, because otherwise it is solved rather than
1522
1583
  // worth a warning.
1523
1584
  if (overheadIsCrowded(fixed, effectiveLimit())) {
@@ -1527,9 +1588,8 @@ detectedContext = { current: 0 }) {
1527
1588
  // OLLAMA_CONTEXT_LENGTH is read by the server, so for an endpoint on another machine it
1528
1589
  // has to be set there. The advice said "in Ollama's environment", which for a remote box
1529
1590
  // sends someone to restart the wrong one.
1530
- remote: !isPrivateHost(resolveProvider(config.provider, config.baseURL).baseURL)
1531
- ? false
1532
- : !/localhost|127\.0\.0\.1|::1/.test(resolveProvider(config.provider, config.baseURL).baseURL),
1591
+ // Loopback means this machine; a LAN address is somebody else's. See runsOnThisMachine.
1592
+ remote: !runsOnThisMachine(resolveProvider(config.provider, config.baseURL).baseURL),
1533
1593
  });
1534
1594
  config.onCrowdedWindow?.(fixed, effectiveLimit(), advice);
1535
1595
  if (!silent)
@@ -1861,9 +1921,17 @@ detectedContext = { current: 0 }) {
1861
1921
  truncationSeen = true;
1862
1922
  contextLimit = plateau.window;
1863
1923
  config.contextTokens = plateau.window; // so compaction plans around the real figure
1864
- config.onTruncation?.(plateau.window, truncationNotice(plateau.window));
1924
+ const endpoint = resolveProvider(config.provider, config.baseURL).baseURL;
1925
+ const said = truncationNotice(plateau.window, {
1926
+ floor: fixedCost,
1927
+ endpoint,
1928
+ // Whether the thing to reconfigure is this machine or another one. KONECK knows its
1929
+ // own platform and nothing about a remote box, so a remote answer names no commands.
1930
+ remote: !runsOnThisMachine(endpoint),
1931
+ });
1932
+ config.onTruncation?.(plateau.window, said);
1865
1933
  if (!silent)
1866
- tui.printWarning(truncationNotice(plateau.window));
1934
+ tui.printWarning(said);
1867
1935
  }
1868
1936
  }
1869
1937
  }
@@ -2341,6 +2409,36 @@ detectedContext = { current: 0 }) {
2341
2409
  }
2342
2410
  }
2343
2411
  }
2412
+ /*
2413
+ * A reply that denies a capability this run has.
2414
+ *
2415
+ * "I cannot visit websites or perform login actions" — with the browser tool enabled, whose
2416
+ * description says it opens a url, fills form fields and clicks. The user's answer to that was
2417
+ * "but there's a built in browser right here", and they were right.
2418
+ *
2419
+ * Checked only after every recovery above has failed to find a call, because a model that wrote
2420
+ * a call as prose has not denied anything. And only for tools actually passed this turn: lean
2421
+ * mode defers tools and a machine may have no browser, and in those cases the denial is true.
2422
+ *
2423
+ * Once per user message. The correction goes to the model and the turn is taken again, because
2424
+ * being told the agent cannot help when it can is the worst outcome available — but a model that
2425
+ * says it a second time is not going to be argued into it, and the reader is better served by
2426
+ * the note than by a third attempt.
2427
+ */
2428
+ if (toolCalls.length === 0 && assistantContent && !capabilityCorrected) {
2429
+ const denied = deniedCapabilities(assistantContent, openaiTools.map(t => t.function.name));
2430
+ if (denied.length > 0) {
2431
+ capabilityCorrected = true;
2432
+ const notice = capabilityNotice(denied, config.model);
2433
+ config.onFalseDenial?.(denied.map(d => d.what), notice);
2434
+ if (!silent)
2435
+ tui.printWarning(notice);
2436
+ messages.push({ role: 'assistant', content: assistantContent });
2437
+ messages.push({ role: 'system', content: capabilityCorrection(denied) });
2438
+ assistantContent = '';
2439
+ continue;
2440
+ }
2441
+ }
2344
2442
  // Only print buffered content if it wasn't already live-streamed
2345
2443
  if (!silent && assistantContent.trim() && !tui.hadStreamOutput())
2346
2444
  tui.printAssistant(assistantContent);
@@ -2449,7 +2547,9 @@ detectedContext = { current: 0 }) {
2449
2547
  // tools — and it said the model was not usable, about a model that had just called
2450
2548
  // `plan` successfully one turn earlier. Neither helped, and both pointed away from the
2451
2549
  // likely cause.
2452
- throw new Error(emptyResponseAdvice(config.provider, config.model, emptyTurns, toolsProvenToWork, resolveProvider(config.provider, config.baseURL).baseURL));
2550
+ throw new Error(emptyResponseAdvice(config.provider, config.model, emptyTurns, toolsProvenToWork, resolveProvider(config.provider, config.baseURL).baseURL,
2551
+ // The floor this run actually has, rather than a constant in a string.
2552
+ fixedCost));
2453
2553
  }
2454
2554
  }
2455
2555
  else {
@@ -2571,7 +2671,36 @@ detectedContext = { current: 0 }) {
2571
2671
  function: { name: tc.name, arguments: tc.arguments },
2572
2672
  })),
2573
2673
  });
2574
- for (const tc of toolCalls) {
2674
+ for (const [callIndex, tc] of toolCalls.entries()) {
2675
+ /*
2676
+ * Stop means stop, including the calls already queued behind this one.
2677
+ *
2678
+ * A turn commonly arrives with several calls in it, and this loop ran every one of them
2679
+ * whatever the user had pressed — there was no check here at all. So stopping during a turn
2680
+ * that had asked for eight commands meant waiting for eight commands. Pressing it again did
2681
+ * nothing except abort an already-aborted signal, which is exactly the "I have to click many
2682
+ * times before it stops" that was reported: each press was honoured instantly and none of
2683
+ * them reached the thing actually taking the time.
2684
+ *
2685
+ * The ones not run still get an answer. Every tool_call in an assistant message must have a
2686
+ * matching tool result or the next request is rejected outright — so abandoning them silently
2687
+ * would trade a slow stop for a session that cannot be resumed, which is worse. Saying what
2688
+ * happened also keeps the transcript honest about which steps did not run.
2689
+ */
2690
+ if (abortSignal?.aborted) {
2691
+ for (const skipped of toolCalls.slice(callIndex)) {
2692
+ messages.push({
2693
+ role: 'tool', tool_call_id: skipped.id,
2694
+ content: 'NOT RUN: stopped at the user\'s request before this call was made.',
2695
+ });
2696
+ }
2697
+ if (!silent) {
2698
+ tui.printWarning(toolCalls.length - callIndex === 1
2699
+ ? 'Stopped. One queued call was not run.'
2700
+ : `Stopped. ${toolCalls.length - callIndex} queued calls were not run.`);
2701
+ }
2702
+ return false;
2703
+ }
2575
2704
  const sig = tc.name + '::' + tc.arguments;
2576
2705
  /*
2577
2706
  * Recorded on attempt, not on execution.
@@ -2886,14 +3015,16 @@ detectedContext = { current: 0 }) {
2886
3015
  }
2887
3016
  catch { /* use empty */ }
2888
3017
  delegated.agents += parsed.tasks?.length ?? 0;
2889
- result = await handleSpawnAgents(parsed, config, effectiveCwd, stats, delegated);
3018
+ result = await handleSpawnAgents(parsed, config, effectiveCwd, abortSignal, stats, delegated);
2890
3019
  }
2891
3020
  else if (BUILTIN_NAMES.has(tc.name)) {
2892
3021
  result = await dispatchTool(tc.name, tc.arguments, effectiveCwd,
2893
3022
  // Ink owns approvals through `config.approve`. Sending it through the legacy TUI as
2894
3023
  // well created a second readline prompt over Ink's frame, which looked like a hang
2895
3024
  // and could leave two terminal interfaces fighting for stdin.
2896
- !config.approve && (config.requireApproval || policyDecision.requireApproval), line => config.onToolOutput?.(tc.name, line), sandbox);
3025
+ !config.approve && (config.requireApproval || policyDecision.requireApproval), line => config.onToolOutput?.(tc.name, line), sandbox,
3026
+ // So a command, a page load or a fetch already in flight is cut rather than waited out.
3027
+ abortSignal);
2897
3028
  }
2898
3029
  else if (plugins.some(pl => pl.definition.name === tc.name)) {
2899
3030
  result = await callPlugin(tc.name, tc.arguments, effectiveCwd, plugins);
@@ -2901,7 +3032,9 @@ detectedContext = { current: 0 }) {
2901
3032
  else if (aliasedTool) {
2902
3033
  // The name is not ours but its meaning is not in doubt: `create_file` with a path and
2903
3034
  // contents is `write_file`. Refusing it wastes the turn and, twice now, the task.
2904
- result = aliasNotice(tc.name, aliasedTool) + await dispatchTool(aliasedTool, tc.arguments, effectiveCwd, !config.approve && (config.requireApproval || policyDecision.requireApproval), line => config.onToolOutput?.(tc.name, line), sandbox);
3035
+ result = aliasNotice(tc.name, aliasedTool) + await dispatchTool(aliasedTool, tc.arguments, effectiveCwd, !config.approve && (config.requireApproval || policyDecision.requireApproval), line => config.onToolOutput?.(tc.name, line), sandbox,
3036
+ // So a command, a page load or a fetch already in flight is cut rather than waited out.
3037
+ abortSignal);
2905
3038
  }
2906
3039
  else {
2907
3040
  // A name the model invented, not a plugin that failed to load. Saying which tools exist