@aragon-agent/cli 0.6.23 → 0.6.25

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +1835 -1786
  2. package/README.md +2578 -2544
  3. package/dist/agent/controller.d.ts +60 -3
  4. package/dist/agent/controller.js +111 -6
  5. package/dist/agent/conversation-reset.d.ts +91 -0
  6. package/dist/agent/conversation-reset.js +66 -0
  7. package/dist/agent/model-probe-limits.d.ts +39 -0
  8. package/dist/agent/model-probe-limits.js +25 -0
  9. package/dist/agent/model-probe.d.ts +104 -0
  10. package/dist/agent/model-probe.js +391 -0
  11. package/dist/agent/model-profile-settings.js +29 -2
  12. package/dist/agent/reducer.js +45 -13
  13. package/dist/commands/builtins.js +101 -37
  14. package/dist/commands/clear-command.d.ts +27 -0
  15. package/dist/commands/clear-command.js +107 -0
  16. package/dist/commands/registry.d.ts +20 -1
  17. package/dist/compaction/child.d.ts +15 -0
  18. package/dist/compaction/child.js +14 -3
  19. package/dist/compaction/compactor.js +1 -0
  20. package/dist/compaction/context-command.js +10 -1
  21. package/dist/compaction/meter.d.ts +9 -0
  22. package/dist/compaction/meter.js +16 -1
  23. package/dist/compaction/pressure.d.ts +7 -0
  24. package/dist/compaction/pressure.js +5 -0
  25. package/dist/compaction/types.d.ts +11 -0
  26. package/dist/compaction/wiring.js +1 -0
  27. package/dist/config/cli-commands.d.ts +1 -1
  28. package/dist/config/cli-commands.js +12 -0
  29. package/dist/config/load.js +24 -6
  30. package/dist/config/model-profile-resolution.d.ts +8 -0
  31. package/dist/config/model-profile-resolution.js +13 -6
  32. package/dist/config/model-profile-store.d.ts +4 -2
  33. package/dist/config/model-profiles.d.ts +21 -0
  34. package/dist/config/model-profiles.js +28 -2
  35. package/dist/config/schema.d.ts +61 -0
  36. package/dist/config/schema.js +36 -0
  37. package/dist/session/clear-archive.d.ts +51 -0
  38. package/dist/session/clear-archive.js +160 -0
  39. package/dist/team/model.d.ts +88 -0
  40. package/dist/team/model.js +136 -0
  41. package/dist/team/report.d.ts +11 -0
  42. package/dist/team/report.js +24 -1
  43. package/dist/team/subagent.js +37 -21
  44. package/dist/team/task-tool.d.ts +25 -6
  45. package/dist/team/task-tool.js +15 -9
  46. package/dist/todo/types.d.ts +5 -3
  47. package/dist/ui/App.js +44 -7
  48. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  49. package/dist/ui/entries/CompactionCard.js +5 -4
  50. package/dist/ui/overlays/HelpOverlay.js +8 -2
  51. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  52. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  53. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  54. package/dist/ui/overlays/ModelProfileEditor.js +117 -54
  55. package/dist/ui/overlays/ModelProfilePicker.d.ts +14 -2
  56. package/dist/ui/overlays/ModelProfilePicker.js +51 -10
  57. package/dist/ui/overlays/QuestionOverlay.js +47 -6
  58. package/dist/ui/overlays/SettingsScreen.d.ts +17 -0
  59. package/dist/ui/overlays/SettingsScreen.js +152 -18
  60. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  61. package/dist/ui/overlays/model-probe-view.js +225 -0
  62. package/dist/ui/overlays/model-profile-state.d.ts +62 -5
  63. package/dist/ui/overlays/model-profile-state.js +121 -14
  64. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  65. package/dist/ui/overlays/use-model-probe.js +109 -0
  66. package/package.json +2 -2
@@ -20,6 +20,7 @@ import { type ConfirmRequest } from '../tools/index.js';
20
20
  import type { ToolPermission } from '../exec/permission.js';
21
21
  import { type ToolOutputListener } from '../tools/tool-output-store.js';
22
22
  import type { FilePatch } from '../tools/patch.js';
23
+ import type { SubagentModelResolution } from '../team/model.js';
23
24
  import type { TeamEvent, TeamSnapshot } from '../team/types.js';
24
25
  import { ProcSupervisor } from '../proc/supervisor.js';
25
26
  import type { ProcEventListener, ServiceSnapshot } from '../proc/types.js';
@@ -30,6 +31,7 @@ import type { CompactionEventListener, CompactionSnapshot, ContextUsageSnapshot
30
31
  import type { TodoEventListener, TodoSnapshot } from '../todo/types.js';
31
32
  import { type HumanInputGate } from '../tools/human-input.js';
32
33
  import { SkillService, type ApprovalGate } from '../skills/service.js';
34
+ import { type ConversationResetOptions, type ConversationResetResult } from './conversation-reset.js';
33
35
  import type { AgentMode } from './agent-mode.js';
34
36
  import type { NoticeLevel } from './reducer.js';
35
37
  /** Approval gate used whenever there is provably no human to ask (headless). */
@@ -478,6 +480,16 @@ export declare class AgentController {
478
480
  * value can never reach the runtime.
479
481
  */
480
482
  setTeamConfig(patch: Partial<TeamConfig>): TeamConfig;
483
+ /**
484
+ * The dispatch-time subagent model resolution (subagent-model §3.4).
485
+ *
486
+ * One authority for the `task` report's disclosure line and its price
487
+ * table: both read through here, so the header can never name one model
488
+ * while pricing another. Re-resolved on every call for the reason
489
+ * `resolveFastTier` records - `/fast off`, a settings save and `/team model`
490
+ * all rewrite the inputs mid-session.
491
+ */
492
+ subagentModelResolution(): SubagentModelResolution;
481
493
  /** Refused mid-dispatch by `/team`; half a dispatch under two ceilings is not
482
494
  * a result anything can afterwards explain (the reasoning `/reload` uses). */
483
495
  isTeamBusy(): boolean;
@@ -537,9 +549,12 @@ export declare class AgentController {
537
549
  /** `/resume`. An absent or empty list CLEARS — see `TodoStore.restore` (P0-2). */
538
550
  restoreTodos(items: unknown): void;
539
551
  /**
540
- * `/todo clear` and `/clear`. A user override of a projection (I-2's standing
541
- * exception). `/todo` refuses mid-run before it gets here; `/clear` does not,
542
- * on purpose — refusing there would leave it unable to clear the transcript.
552
+ * `/todo clear` and `/clear screen`. A user override of a projection (I-2's
553
+ * standing exception). `/todo` refuses mid-run before it gets here;
554
+ * `/clear screen` does not, on purpose - refusing there would leave a user
555
+ * unable to clear the transcript mid-run. `/clear` itself is the conversation
556
+ * boundary: it reaches the store through `clearMessages()` with reason
557
+ * 'reset', after it has interrupted the run.
543
558
  */
544
559
  clearTodos(): void;
545
560
  getRetryConfig(): RetryConfig;
@@ -615,7 +630,49 @@ export declare class AgentController {
615
630
  * `subscribeTeam` / `subscribeTodos` / `subscribeFast` already set.
616
631
  */
617
632
  subscribeToolOutput(listener: ToolOutputListener): () => void;
633
+ /**
634
+ * Clear the conversation history: THE NEW-CONVERSATION BOUNDARY.
635
+ *
636
+ * Six things, and every one of them is session-scoped: cancel the idle
637
+ * compaction, drop the `/resume` identity, clear Core's history, bump
638
+ * `startupSequence`, deep-invalidate compaction and the meter, and clear the
639
+ * skill activation frames. `/resume` has the twin of this in `replaceMessages`,
640
+ * and `/clear` is this one's second caller - see
641
+ * `docs/plans/clear-command-context-reset/spec.md`.
642
+ */
618
643
  clearMessages(): void;
644
+ /**
645
+ * `/clear` (and therefore `/reset`) - the conversation reset, in the one order
646
+ * that is safe.
647
+ *
648
+ * A THIN ADAPTER AND NOTHING ELSE (D-7). The order - interrupt, bounded wait,
649
+ * archive, clear queues, clear history - lives in `agent/conversation-reset.ts`
650
+ * so it can be proven against a six-line fake instead of against a fully
651
+ * constructed controller. Everything below is a wiring decision, and two of
652
+ * them are load-bearing:
653
+ *
654
+ * `interruptRun` IS `forceStop()`, NOT `abort()` (P0-2 / C-1). `abort()` bumps
655
+ * `startupSequence` only, so the events the engine keeps emitting while it
656
+ * unwinds would be adopted by `App` as ordinary events of the new run - the
657
+ * first screen of a fresh conversation would show the previous task's text and
658
+ * tool cards, and the status bar would bounce back out of `idle`. `forceStop()`
659
+ * also increments `runGen`, which is the fence `App` compares every event
660
+ * against, and hard-kills a foreground `bash` that ignores the abort signal -
661
+ * without which the bounded wait below could only ever time out.
662
+ *
663
+ * The session is archived BEFORE the clear and never after (D-9 step 4),
664
+ * because after it there is nothing left to save.
665
+ */
666
+ resetConversation(opts?: ConversationResetOptions): Promise<ConversationResetResult>;
667
+ /**
668
+ * The four parts of the archive input this controller can answer itself.
669
+ *
670
+ * `entries` is NOT one of them (P0-1). The screen transcript lives in `App`'s
671
+ * `ViewState`, and teaching the controller about the view layer to obtain it
672
+ * would invert the dependency this class exists to keep. The command layer
673
+ * passes a thunk instead - see `CommandContext.transcriptEntries`.
674
+ */
675
+ private describeClearedConversation;
619
676
  /** Read-only view of the live system prompt (regression tests, /debug). */
620
677
  getSystemPrompt(): string;
621
678
  replaceMessages(messages: Message[], identity?: CompactionIdentity): void;
@@ -16,6 +16,7 @@ import { createChildReviewer } from '../team/child-reviewer.js';
16
16
  import { TeamHumanQueue } from '../team/human-queue.js';
17
17
  import { createFastOverseerProvider } from '../team/overseer.js';
18
18
  import { createTaskTool } from '../team/task-tool.js';
19
+ import { resolveSubagentModel } from '../team/model.js';
19
20
  import { buildTeamBlock } from '../team/prompt.js';
20
21
  import { buildFastBlock } from '../fast/prompt.js';
21
22
  import { ProcSupervisor } from '../proc/supervisor.js';
@@ -37,6 +38,8 @@ import { createNodeSkillHost } from '../skills/node-host.js';
37
38
  import { SkillService } from '../skills/service.js';
38
39
  import { createSkillTools } from '../skills/tools.js';
39
40
  import { buildSystemPrompt } from './system-prompt.js';
41
+ import { resetConversationWith, } from './conversation-reset.js';
42
+ import { archiveClearedConversation, } from '../session/clear-archive.js';
40
43
  /**
41
44
  * Extra budget, on top of `PROC_LIMITS.killGraceMs`, that `prompt()` allows a
42
45
  * force-stopped engine to unwind in before it starts a new run anyway (I-8).
@@ -516,9 +519,22 @@ export class AgentController {
516
519
  maxSubagents: () => this.config.team.maxSubagents,
517
520
  hasApiKey: () => this.hasApiKey(),
518
521
  activeProvider: () => this.config.provider,
519
- // Every child runs the lead's own model, so the lead's cost table
520
- // is the one honest price for the dispatch total.
521
- modelCost: () => this.getModelInfo().cost,
522
+ // Priced at the model children ACTUALLY run (subagent-model §3.4):
523
+ // under the default `main` mode this is exactly the lead's own
524
+ // table (I-2 byte parity); a fast or profile model the static
525
+ // tables do not price yields undefined, so the report says
526
+ // `pricing unknown` rather than fabricating $0.00 (C-11 / RV-4).
527
+ modelCost: () => {
528
+ const resolution = this.subagentModelResolution();
529
+ if (resolution.effective === 'main')
530
+ return this.getModelInfo().cost;
531
+ return this.isPricedModel(resolution.ref)
532
+ ? this.getModelInfoFor(resolution.ref).cost
533
+ : undefined;
534
+ },
535
+ // The same resolution, re-read per dispatch, feeds the report's
536
+ // disclosure line - one read per consumer, one authority both.
537
+ subagentModel: () => this.subagentModelResolution(),
522
538
  // The FAST table for the supervisor's own spend line
523
539
  // (subagent-overseer-v2 D-7 / R-P1-2). Reuses the tier
524
540
  // resolution and the unknown-pricing predicate the fast
@@ -575,6 +591,10 @@ export class AgentController {
575
591
  isWindowKnown: () => this.getModelInfo().contextWindowSource !== 'fallback',
576
592
  // LIVE, so a settings-screen edit moves the denominator without a relaunch.
577
593
  getWindowOverride: () => this.config.contextWindow,
594
+ // Beside the value, LIVE too: `/context` must name the surface that
595
+ // supplied the denominator (profile-context-window AC-9, one of four
596
+ // meter sites that must stay in lockstep).
597
+ getWindowOverrideOrigin: () => this.config.contextWindowOrigin,
578
598
  });
579
599
  // --- Context compaction, part 1: BEFORE the Agent ----------------------
580
600
  //
@@ -1307,6 +1327,18 @@ export class AgentController {
1307
1327
  this.rebuildSystemPrompt();
1308
1328
  return team;
1309
1329
  }
1330
+ /**
1331
+ * The dispatch-time subagent model resolution (subagent-model §3.4).
1332
+ *
1333
+ * One authority for the `task` report's disclosure line and its price
1334
+ * table: both read through here, so the header can never name one model
1335
+ * while pricing another. Re-resolved on every call for the reason
1336
+ * `resolveFastTier` records - `/fast off`, a settings save and `/team model`
1337
+ * all rewrite the inputs mid-session.
1338
+ */
1339
+ subagentModelResolution() {
1340
+ return resolveSubagentModel(this.config, (id, role) => this.hasApiKey(id, role));
1341
+ }
1310
1342
  /** Refused mid-dispatch by `/team`; half a dispatch under two ceilings is not
1311
1343
  * a result anything can afterwards explain (the reasoning `/reload` uses). */
1312
1344
  isTeamBusy() {
@@ -1415,9 +1447,12 @@ export class AgentController {
1415
1447
  this.todos?.restore(items);
1416
1448
  }
1417
1449
  /**
1418
- * `/todo clear` and `/clear`. A user override of a projection (I-2's standing
1419
- * exception). `/todo` refuses mid-run before it gets here; `/clear` does not,
1420
- * on purpose — refusing there would leave it unable to clear the transcript.
1450
+ * `/todo clear` and `/clear screen`. A user override of a projection (I-2's
1451
+ * standing exception). `/todo` refuses mid-run before it gets here;
1452
+ * `/clear screen` does not, on purpose - refusing there would leave a user
1453
+ * unable to clear the transcript mid-run. `/clear` itself is the conversation
1454
+ * boundary: it reaches the store through `clearMessages()` with reason
1455
+ * 'reset', after it has interrupted the run.
1421
1456
  */
1422
1457
  clearTodos() {
1423
1458
  this.todos?.clear('user');
@@ -1667,6 +1702,16 @@ export class AgentController {
1667
1702
  this.outputListeners.delete(listener);
1668
1703
  };
1669
1704
  }
1705
+ /**
1706
+ * Clear the conversation history: THE NEW-CONVERSATION BOUNDARY.
1707
+ *
1708
+ * Six things, and every one of them is session-scoped: cancel the idle
1709
+ * compaction, drop the `/resume` identity, clear Core's history, bump
1710
+ * `startupSequence`, deep-invalidate compaction and the meter, and clear the
1711
+ * skill activation frames. `/resume` has the twin of this in `replaceMessages`,
1712
+ * and `/clear` is this one's second caller - see
1713
+ * `docs/plans/clear-command-context-reset/spec.md`.
1714
+ */
1670
1715
  clearMessages() {
1671
1716
  this.idleCompaction?.abort();
1672
1717
  this.restoredCompactionIdentity = undefined;
@@ -1693,6 +1738,66 @@ export class AgentController {
1693
1738
  // overriding a projection of a belief the model still holds.
1694
1739
  this.todos?.clear('reset');
1695
1740
  }
1741
+ /**
1742
+ * `/clear` (and therefore `/reset`) - the conversation reset, in the one order
1743
+ * that is safe.
1744
+ *
1745
+ * A THIN ADAPTER AND NOTHING ELSE (D-7). The order - interrupt, bounded wait,
1746
+ * archive, clear queues, clear history - lives in `agent/conversation-reset.ts`
1747
+ * so it can be proven against a six-line fake instead of against a fully
1748
+ * constructed controller. Everything below is a wiring decision, and two of
1749
+ * them are load-bearing:
1750
+ *
1751
+ * `interruptRun` IS `forceStop()`, NOT `abort()` (P0-2 / C-1). `abort()` bumps
1752
+ * `startupSequence` only, so the events the engine keeps emitting while it
1753
+ * unwinds would be adopted by `App` as ordinary events of the new run - the
1754
+ * first screen of a fresh conversation would show the previous task's text and
1755
+ * tool cards, and the status bar would bounce back out of `idle`. `forceStop()`
1756
+ * also increments `runGen`, which is the fence `App` compares every event
1757
+ * against, and hard-kills a foreground `bash` that ignores the abort signal -
1758
+ * without which the bounded wait below could only ever time out.
1759
+ *
1760
+ * The session is archived BEFORE the clear and never after (D-9 step 4),
1761
+ * because after it there is nothing left to save.
1762
+ */
1763
+ async resetConversation(opts = {}) {
1764
+ return resetConversationWith({
1765
+ isRunning: () => this.isRunning(),
1766
+ interruptRun: () => this.forceStop(),
1767
+ awaitEngineIdle: () => this.waitForEngineIdle().then(() => !this.isRunning()),
1768
+ archive: (readTranscript) => archiveClearedConversation({
1769
+ ...this.describeClearedConversation(),
1770
+ entries: readTranscript(),
1771
+ }),
1772
+ clearQueues: () => this.clearAllQueues(),
1773
+ clearHistory: () => {
1774
+ const cleared = this.agent.state.messages.length;
1775
+ this.clearMessages();
1776
+ return cleared;
1777
+ },
1778
+ }, opts);
1779
+ }
1780
+ /**
1781
+ * The four parts of the archive input this controller can answer itself.
1782
+ *
1783
+ * `entries` is NOT one of them (P0-1). The screen transcript lives in `App`'s
1784
+ * `ViewState`, and teaching the controller about the view layer to obtain it
1785
+ * would invert the dependency this class exists to keep. The command layer
1786
+ * passes a thunk instead - see `CommandContext.transcriptEntries`.
1787
+ */
1788
+ describeClearedConversation() {
1789
+ const snapshot = this.getSessionSnapshot();
1790
+ return {
1791
+ model: {
1792
+ providerId: this.config.provider,
1793
+ modelId: this.config.model,
1794
+ ...(this.config.baseUrl ? { baseUrl: this.config.baseUrl } : {}),
1795
+ },
1796
+ messages: snapshot.messages,
1797
+ ...(snapshot.compactionIdentity ? { compactionIdentity: snapshot.compactionIdentity } : {}),
1798
+ todos: this.getTodoSnapshot()?.items ?? [],
1799
+ };
1800
+ }
1696
1801
  /** Read-only view of the live system prompt (regression tests, /debug). */
1697
1802
  getSystemPrompt() {
1698
1803
  return this.agent.state.systemPrompt;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The single authority on the ORDER of a conversation reset
3
+ * (clear-command-context-reset §3.4 / D-9).
4
+ *
5
+ * ASCII ONLY: `src/agent/**` is inside `glyphs.test.ts::inScope`, and this module
6
+ * contributes transcript text (the "could not stop" message) through the command
7
+ * layer.
8
+ *
9
+ * WHY A PORT RATHER THAN A METHOD ON `AgentController`: the order - interrupt,
10
+ * bounded wait, archive, clear queues, clear history - is the thing that has a
11
+ * correctness proof attached to it (a clear that lands before the engine unwinds
12
+ * pollutes the new conversation, or produces a `tool_use` with no `tool_result`).
13
+ * Written as a method, that order can only be tested by constructing a controller
14
+ * and everything it wires. Written against a port, the order itself is the thing
15
+ * under test, and every failure branch is reachable from a six-line fake.
16
+ *
17
+ * `interruptRun` IS NOT CALLED `abort`, ON PURPOSE (P0-2). It maps to
18
+ * `controller.forceStop()`, which does three things `abort()` does not: it
19
+ * hard-kills foreground `bash` children (the only thing that unblocks a tool that
20
+ * ignores the abort signal), and it bumps `runGen` - the generation fence `App`
21
+ * compares every engine event against. A method named `abort` that quietly does
22
+ * more than the existing `abort()` would be the exact kind of misleading name
23
+ * this feature exists to remove.
24
+ */
25
+ import type { Entry } from './reducer.js';
26
+ /** The minimal host surface a reset needs. Supplied by `AgentController` alone. */
27
+ export interface ConversationResetPort {
28
+ /** Whether a run is in flight right now. */
29
+ isRunning(): boolean;
30
+ /**
31
+ * Interrupt the current run: `controller.forceStop()` (P0-2).
32
+ *
33
+ * = `abort()` (stops team / fast / compaction) + `killForeground('force')`
34
+ * + `runGen += 1`. Background services are deliberately untouched.
35
+ */
36
+ interruptRun(): void;
37
+ /** Bounded wait for the engine to unwind; `true` means it settled. */
38
+ awaitEngineIdle(): Promise<boolean>;
39
+ /**
40
+ * Best-effort archive of the old conversation; an absolute path, or `null`.
41
+ *
42
+ * `readTranscript` is supplied by the caller because the screen transcript lives
43
+ * in `ViewState`, which the controller does not and must not know about (P0-1).
44
+ * Implementations never throw.
45
+ */
46
+ archive(readTranscript: () => Entry[]): string | null;
47
+ /** Drop Core's two queues plus the CLI's pending steering list. */
48
+ clearQueues(): void;
49
+ /** Clear the history (the six-part boundary) and return the message count. */
50
+ clearHistory(): number;
51
+ }
52
+ export interface ConversationResetOptions {
53
+ /** Interrupt a running engine (default `true`). `false` refuses instead. */
54
+ interrupt?: boolean;
55
+ /** Read the screen transcript for the archive. Absent = history-only archive. */
56
+ readTranscript?: () => Entry[];
57
+ }
58
+ export type ConversationResetResult = {
59
+ ok: true;
60
+ interrupted: boolean;
61
+ archivedPath: string | null;
62
+ messagesCleared: number;
63
+ } | {
64
+ ok: false;
65
+ reason: 'still-running';
66
+ message: string;
67
+ };
68
+ /**
69
+ * The one message a failed reset shows. It names the NEXT ACTION rather than the
70
+ * failure, because `forceStop()` already ran: what is left to try is the human's
71
+ * own hand.
72
+ */
73
+ export declare const CLEAR_STILL_RUNNING_MESSAGE = "Could not stop the current run. Press Esc twice to force-stop, then /clear.";
74
+ /**
75
+ * Reset the conversation, in the only order that is safe.
76
+ *
77
+ * THE ORDER IS THE DESIGN (D-9). Each step exists because of what happens if it
78
+ * moves:
79
+ * 1. interrupt - a clear that does not stop the loop first is overwritten by the
80
+ * loop's next `tool_result` append;
81
+ * 2. bounded wait - the interrupt is a request; `agent.abort()` resolves nothing
82
+ * by itself, so clearing now still races the unwind;
83
+ * 3. archive - BEFORE the clear, because after it there is nothing left to save;
84
+ * 4. clear queues - before the history, so a drain cannot re-feed the old queue;
85
+ * 5. clear history - the six-part boundary plus `startupSequence`.
86
+ *
87
+ * A FAILED WAIT CLEARS NOTHING (I-4). Half a reset - queues emptied, history
88
+ * still there - is worse than no reset at all, because the view and the engine
89
+ * then disagree with no visible cause.
90
+ */
91
+ export declare function resetConversationWith(port: ConversationResetPort, opts?: ConversationResetOptions): Promise<ConversationResetResult>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The single authority on the ORDER of a conversation reset
3
+ * (clear-command-context-reset §3.4 / D-9).
4
+ *
5
+ * ASCII ONLY: `src/agent/**` is inside `glyphs.test.ts::inScope`, and this module
6
+ * contributes transcript text (the "could not stop" message) through the command
7
+ * layer.
8
+ *
9
+ * WHY A PORT RATHER THAN A METHOD ON `AgentController`: the order - interrupt,
10
+ * bounded wait, archive, clear queues, clear history - is the thing that has a
11
+ * correctness proof attached to it (a clear that lands before the engine unwinds
12
+ * pollutes the new conversation, or produces a `tool_use` with no `tool_result`).
13
+ * Written as a method, that order can only be tested by constructing a controller
14
+ * and everything it wires. Written against a port, the order itself is the thing
15
+ * under test, and every failure branch is reachable from a six-line fake.
16
+ *
17
+ * `interruptRun` IS NOT CALLED `abort`, ON PURPOSE (P0-2). It maps to
18
+ * `controller.forceStop()`, which does three things `abort()` does not: it
19
+ * hard-kills foreground `bash` children (the only thing that unblocks a tool that
20
+ * ignores the abort signal), and it bumps `runGen` - the generation fence `App`
21
+ * compares every engine event against. A method named `abort` that quietly does
22
+ * more than the existing `abort()` would be the exact kind of misleading name
23
+ * this feature exists to remove.
24
+ */
25
+ /**
26
+ * The one message a failed reset shows. It names the NEXT ACTION rather than the
27
+ * failure, because `forceStop()` already ran: what is left to try is the human's
28
+ * own hand.
29
+ */
30
+ export const CLEAR_STILL_RUNNING_MESSAGE = 'Could not stop the current run. Press Esc twice to force-stop, then /clear.';
31
+ /**
32
+ * Reset the conversation, in the only order that is safe.
33
+ *
34
+ * THE ORDER IS THE DESIGN (D-9). Each step exists because of what happens if it
35
+ * moves:
36
+ * 1. interrupt - a clear that does not stop the loop first is overwritten by the
37
+ * loop's next `tool_result` append;
38
+ * 2. bounded wait - the interrupt is a request; `agent.abort()` resolves nothing
39
+ * by itself, so clearing now still races the unwind;
40
+ * 3. archive - BEFORE the clear, because after it there is nothing left to save;
41
+ * 4. clear queues - before the history, so a drain cannot re-feed the old queue;
42
+ * 5. clear history - the six-part boundary plus `startupSequence`.
43
+ *
44
+ * A FAILED WAIT CLEARS NOTHING (I-4). Half a reset - queues emptied, history
45
+ * still there - is worse than no reset at all, because the view and the engine
46
+ * then disagree with no visible cause.
47
+ */
48
+ export async function resetConversationWith(port, opts = {}) {
49
+ const interrupt = opts.interrupt ?? true;
50
+ const running = port.isRunning();
51
+ if (running && !interrupt) {
52
+ return { ok: false, reason: 'still-running', message: CLEAR_STILL_RUNNING_MESSAGE };
53
+ }
54
+ if (running) {
55
+ port.interruptRun();
56
+ const settled = await port.awaitEngineIdle();
57
+ if (!settled) {
58
+ return { ok: false, reason: 'still-running', message: CLEAR_STILL_RUNNING_MESSAGE };
59
+ }
60
+ }
61
+ const readTranscript = opts.readTranscript ?? (() => []);
62
+ const archivedPath = port.archive(readTranscript);
63
+ port.clearQueues();
64
+ const messagesCleared = port.clearHistory();
65
+ return { ok: true, interrupted: running, archivedPath, messagesCleared };
66
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Structural bounds for the profile connection probe (model-profile-connection-test §7).
3
+ *
4
+ * STRUCTURAL, NOT POLICY: nothing here is clamped, persisted or user-tunable. These
5
+ * are the physical limits of one probe - how long a single round trip may take, how
6
+ * many lines and characters a result may occupy, how many models a list page can
7
+ * carry before it must truncate. The user-tunable keys live in `config/schema.ts`.
8
+ *
9
+ * THE OUTPUT-TOKEN CAP IS DELIBERATELY ABSENT. It must come from core's
10
+ * `MIN_MAX_OUTPUT_TOKENS`, or this file becomes a second authority for "how many
11
+ * tokens may this model produce" - which is exactly what `output-limits.ts` exists
12
+ * to prevent.
13
+ *
14
+ * ASCII ONLY: this module lives in `agent/`, which `glyphs.test.ts::inScope` scans.
15
+ */
16
+ export interface ProbeLimits {
17
+ /** Ceiling on ONE real round trip (`complete`). */
18
+ testTimeoutMs: number;
19
+ /**
20
+ * Sub-budget for the best-effort model list that runs CONCURRENTLY with the
21
+ * completion. It never decides the verdict (§4.3 / RV-P1-1): a slow `/models`
22
+ * that ate the adapter's own 15 s discovery timeout would turn a healthy
23
+ * connection into a false `timeout`.
24
+ */
25
+ listModelsBudgetMs: number;
26
+ /** Ceiling on a discovery (`listModels`) issued from the list page. */
27
+ discoveryTimeoutMs: number;
28
+ /** Characters of the model's reply kept for the `Reply:` line. */
29
+ replyChars: number;
30
+ /** Characters of a locally composed failure sentence. */
31
+ detailChars: number;
32
+ /** Rows a list page may carry before `truncated` becomes true. */
33
+ maxListedModels: number;
34
+ /** Above this many rows the list page grows a search line (picker parity). */
35
+ searchThreshold: number;
36
+ /** Characters of a model's display name kept in one list row. */
37
+ maxNameChars: number;
38
+ }
39
+ export declare const PROBE_LIMITS: ProbeLimits;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Structural bounds for the profile connection probe (model-profile-connection-test §7).
3
+ *
4
+ * STRUCTURAL, NOT POLICY: nothing here is clamped, persisted or user-tunable. These
5
+ * are the physical limits of one probe - how long a single round trip may take, how
6
+ * many lines and characters a result may occupy, how many models a list page can
7
+ * carry before it must truncate. The user-tunable keys live in `config/schema.ts`.
8
+ *
9
+ * THE OUTPUT-TOKEN CAP IS DELIBERATELY ABSENT. It must come from core's
10
+ * `MIN_MAX_OUTPUT_TOKENS`, or this file becomes a second authority for "how many
11
+ * tokens may this model produce" - which is exactly what `output-limits.ts` exists
12
+ * to prevent.
13
+ *
14
+ * ASCII ONLY: this module lives in `agent/`, which `glyphs.test.ts::inScope` scans.
15
+ */
16
+ export const PROBE_LIMITS = {
17
+ testTimeoutMs: 20_000,
18
+ listModelsBudgetMs: 5_000,
19
+ discoveryTimeoutMs: 20_000,
20
+ replyChars: 60,
21
+ detailChars: 160,
22
+ maxListedModels: 400,
23
+ searchThreshold: 8,
24
+ maxNameChars: 48,
25
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The connection probe behind `Test connection` and `Discover models`
3
+ * (model-profile-connection-test §4.2-§4.8).
4
+ *
5
+ * WHY THIS IS NOT `ModelRegistry.discoverModels()`: that path folds every exception
6
+ * into `[]` and stores the result in a five-minute process-wide cache. Using it here
7
+ * would (a) collapse "wrong key", "wrong URL" and "this account owns no models" into
8
+ * one sentence, and (b) let a probe of an UNSAVED draft endpoint poison `/model`,
9
+ * `/context` and the main session's window resolution for five minutes. So the probe
10
+ * talks to the adapter directly and classifies by `LLMError.errorType`.
11
+ *
12
+ * WHY `complete()` AND NOT ONLY `listModels()`: a model can be listed and still be
13
+ * unable to generate (gateway aliases, permission layers). One real round trip is the
14
+ * only honest answer to "does this work right now", and it is also the only evidence
15
+ * that can produce `model_not_found`.
16
+ *
17
+ * ASCII ONLY: `agent/` is inside `glyphs.test.ts::inScope`.
18
+ */
19
+ import { type ProviderRegistry } from '@aragon-agent/core';
20
+ /** Every way a probe can fail, once and for all (§7). */
21
+ export type ProbeFailureKind = 'missing_key' | 'unsupported_provider' | 'invalid_base_url' | 'auth' | 'model_not_found' | 'invalid_request' | 'rate_limit' | 'provider_unavailable' | 'network' | 'timeout' | 'cancelled' | 'unknown';
22
+ export interface ProbeTarget {
23
+ provider: string;
24
+ /** `''` means "no model typed yet" - the probe then only lists models. */
25
+ model: string;
26
+ baseUrl: string | null;
27
+ /** The RESOLVED plaintext key from `resolveDraftKey()`, never `editor.apiKey`. */
28
+ apiKey: string | null;
29
+ }
30
+ export interface ProbeRequest extends ProbeTarget {
31
+ signal?: AbortSignal;
32
+ }
33
+ export interface ProbeOutcome {
34
+ ok: boolean;
35
+ /** Fingerprint of the target this outcome describes (§4.6). */
36
+ targetId: string;
37
+ latencyMs: number;
38
+ kind?: ProbeFailureKind;
39
+ /** Locally composed, already redacted sentence. Never provider response text. */
40
+ detail?: string;
41
+ reply?: string;
42
+ usage?: {
43
+ inputTokens: number;
44
+ outputTokens: number;
45
+ };
46
+ availableModels?: number;
47
+ modelListed?: boolean;
48
+ }
49
+ export interface ModelCandidate {
50
+ id: string;
51
+ name: string;
52
+ contextWindow?: number;
53
+ supportsTools: boolean;
54
+ }
55
+ export interface DiscoveryOutcome {
56
+ ok: boolean;
57
+ connectionId: string;
58
+ kind?: ProbeFailureKind;
59
+ detail?: string;
60
+ models: ModelCandidate[];
61
+ truncated: boolean;
62
+ /**
63
+ * How many models the endpoint returned BEFORE truncation. The truncation line
64
+ * needs the real number ("Showing first 400 of 960"), and re-deriving it from a
65
+ * clamped array is impossible.
66
+ */
67
+ total?: number;
68
+ }
69
+ export interface ModelProbe {
70
+ test(request: ProbeRequest): Promise<ProbeOutcome>;
71
+ discover(request: ProbeRequest): Promise<DiscoveryOutcome>;
72
+ targetFingerprint(target: ProbeTarget): string;
73
+ connectionFingerprint(target: ProbeTarget): string;
74
+ }
75
+ export interface ProbeDeps {
76
+ registry: Pick<ProviderRegistry, 'get'>;
77
+ /** Injected clock; tests pin `latencyMs` through it. */
78
+ now?: () => number;
79
+ }
80
+ /**
81
+ * Which key this draft would actually use (§4.2.1).
82
+ *
83
+ * The editor's `apiKey` field is ALWAYS the empty string: it is the replacement
84
+ * input, not the live credential. `finishProfileEditor` resolves `keep` to the
85
+ * saved entry key and `shared` to `null`, and the runtime then falls back to the
86
+ * shared per-provider key. Without this table the most common path - editing an
87
+ * existing profile, whose credential mode defaults to `keep` - would report
88
+ * `(no key)` for a profile that has one.
89
+ */
90
+ export declare function resolveDraftKey(input: {
91
+ credentialMode: 'keep' | 'replace' | 'shared';
92
+ draftKey: string;
93
+ existingKey: string | null;
94
+ sharedKey?: string;
95
+ }): string | null;
96
+ /**
97
+ * The host a failure sentence names - same four clauses as
98
+ * `profileEndpointSummary()` in `ui/overlays/ModelProfilePicker.tsx`, which this
99
+ * layer may not import (agent -> ui would invert the layering). `model-probe.test.ts`
100
+ * asserts the two agree, so the duplication cannot drift silently.
101
+ */
102
+ export declare function endpointHost(baseUrl: string | null): string;
103
+ /** Create the probe port. One instance per App; `SettingsScreen` only passes it down. */
104
+ export declare function createModelProbe(deps: ProbeDeps): ModelProbe;