@otto-code/protocol 0.6.2 → 0.6.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.
@@ -0,0 +1,26 @@
1
+ /** Hard cap so no provider summary can produce a wall-of-text label. */
2
+ export declare const OBSERVED_SUBAGENT_TITLE_MAX = 60;
3
+ export declare function normalizeObservedTitleSource(value: string | undefined | null): string | null;
4
+ export declare function normalizeObservedSubagentType(value: string | undefined | null): string | null;
5
+ /**
6
+ * Derive the frozen row name for an observed subagent. Prefer the stable
7
+ * `subAgentType` (e.g. "code-explorer") over the description — a
8
+ * `task_progress` description is the ever-changing AI summary, which must
9
+ * never become the label. Generic catch-all types ("general-purpose") are
10
+ * skipped in favor of the description. Callers freeze the result at the first
11
+ * named update.
12
+ */
13
+ export declare function deriveObservedSubagentTitle(update: {
14
+ subAgentType?: string;
15
+ description?: string;
16
+ }): string;
17
+ /**
18
+ * True when this update carries a real name source we can freeze the title on.
19
+ * A generic catch-all type alone doesn't count — freezing "general-purpose"
20
+ * would lock out the description a later update may carry.
21
+ */
22
+ export declare function observedUpdateHasTitleSource(update: {
23
+ subAgentType?: string;
24
+ description?: string;
25
+ }): boolean;
26
+ //# sourceMappingURL=observed-subagent-title.d.ts.map
@@ -0,0 +1,63 @@
1
+ // Shared naming rule for provider-managed (observed) subagents. This is the
2
+ // single source of truth for how an observed subagent is titled — the daemon
3
+ // uses it to freeze the track-row label (agent-projections.ts), and the app's
4
+ // visualizer uses it so page-side child labels (subagent_dispatch/return
5
+ // particles ride an edge keyed by child NAME) resolve to exactly the same
6
+ // string as the child node the daemon-titled agent row spawned. If the two
7
+ // ever diverge, the dispatch/return visuals silently stop rendering.
8
+ // See docs/agent-lifecycle.md (Item 4) and docs/visualizer.md.
9
+ /** Hard cap so no provider summary can produce a wall-of-text label. */
10
+ export const OBSERVED_SUBAGENT_TITLE_MAX = 60;
11
+ /**
12
+ * Catch-all subagent types that make lousy row labels — Claude's default Task
13
+ * runs as "general-purpose". These never become the title; the task
14
+ * description names the row instead (user-locked).
15
+ */
16
+ const GENERIC_OBSERVED_SUBAGENT_TYPES = new Set([
17
+ "general-purpose",
18
+ "general",
19
+ "task",
20
+ "agent",
21
+ "subagent",
22
+ ]);
23
+ export function normalizeObservedTitleSource(value) {
24
+ if (typeof value !== "string") {
25
+ return null;
26
+ }
27
+ const collapsed = value.replace(/\s+/g, " ").trim();
28
+ return collapsed.length > 0 ? collapsed : null;
29
+ }
30
+ export function normalizeObservedSubagentType(value) {
31
+ const normalized = normalizeObservedTitleSource(value);
32
+ if (normalized === null || GENERIC_OBSERVED_SUBAGENT_TYPES.has(normalized.toLowerCase())) {
33
+ return null;
34
+ }
35
+ return normalized;
36
+ }
37
+ /**
38
+ * Derive the frozen row name for an observed subagent. Prefer the stable
39
+ * `subAgentType` (e.g. "code-explorer") over the description — a
40
+ * `task_progress` description is the ever-changing AI summary, which must
41
+ * never become the label. Generic catch-all types ("general-purpose") are
42
+ * skipped in favor of the description. Callers freeze the result at the first
43
+ * named update.
44
+ */
45
+ export function deriveObservedSubagentTitle(update) {
46
+ const base = normalizeObservedSubagentType(update.subAgentType) ??
47
+ normalizeObservedTitleSource(update.description) ??
48
+ "Subagent";
49
+ if (base.length <= OBSERVED_SUBAGENT_TITLE_MAX) {
50
+ return base;
51
+ }
52
+ return `${base.slice(0, OBSERVED_SUBAGENT_TITLE_MAX - 1).trimEnd()}…`;
53
+ }
54
+ /**
55
+ * True when this update carries a real name source we can freeze the title on.
56
+ * A generic catch-all type alone doesn't count — freezing "general-purpose"
57
+ * would lock out the description a later update may carry.
58
+ */
59
+ export function observedUpdateHasTitleSource(update) {
60
+ return (normalizeObservedSubagentType(update.subAgentType) !== null ||
61
+ normalizeObservedTitleSource(update.description) !== null);
62
+ }
63
+ //# sourceMappingURL=observed-subagent-title.js.map
@@ -86,6 +86,16 @@ export declare const MCP_TOOL_PERMISSION_MODES: readonly ["always-ask", "trust-r
86
86
  * conversation is compacted automatically.
87
87
  */
88
88
  export declare const COMPACTION_THRESHOLD_PERCENTS: readonly [50, 60, 70, 80, 90];
89
+ /**
90
+ * Max model→tool→model rounds per turn for providers whose tool loop the
91
+ * daemon owns (openai-compat). The turn stops with an error after this many
92
+ * rounds without a final answer — a runaway-loop safety valve, most often hit
93
+ * by smaller local models that keep calling tools instead of converging.
94
+ * Bounds keep the setting a sane guard rail rather than an off switch.
95
+ */
96
+ export declare const MAX_TOOL_ROUNDS_DEFAULT = 50;
97
+ export declare const MAX_TOOL_ROUNDS_MIN = 1;
98
+ export declare const MAX_TOOL_ROUNDS_MAX = 1000;
89
99
  /**
90
100
  * Compaction tuning for providers whose conversation the daemon owns
91
101
  * (openai-compat). These set the provider-level defaults; the per-agent
@@ -167,6 +177,7 @@ export declare const ProviderOverrideSchema: z.ZodObject<{
167
177
  keepRecentTokens: z.ZodOptional<z.ZodNumber>;
168
178
  hideSelector: z.ZodOptional<z.ZodBoolean>;
169
179
  }, z.core.$strip>>;
180
+ maxToolRounds: z.ZodOptional<z.ZodNumber>;
170
181
  enabled: z.ZodOptional<z.ZodBoolean>;
171
182
  order: z.ZodOptional<z.ZodNumber>;
172
183
  }, z.core.$strip>;
@@ -239,6 +250,7 @@ export declare const ProviderOverridesSchema: z.ZodRecord<z.ZodString, z.ZodObje
239
250
  keepRecentTokens: z.ZodOptional<z.ZodNumber>;
240
251
  hideSelector: z.ZodOptional<z.ZodBoolean>;
241
252
  }, z.core.$strip>>;
253
+ maxToolRounds: z.ZodOptional<z.ZodNumber>;
242
254
  enabled: z.ZodOptional<z.ZodBoolean>;
243
255
  order: z.ZodOptional<z.ZodNumber>;
244
256
  }, z.core.$strip>>;
@@ -117,6 +117,16 @@ export const MCP_TOOL_PERMISSION_MODES = ["always-ask", "trust-read-only"];
117
117
  * conversation is compacted automatically.
118
118
  */
119
119
  export const COMPACTION_THRESHOLD_PERCENTS = [50, 60, 70, 80, 90];
120
+ /**
121
+ * Max model→tool→model rounds per turn for providers whose tool loop the
122
+ * daemon owns (openai-compat). The turn stops with an error after this many
123
+ * rounds without a final answer — a runaway-loop safety valve, most often hit
124
+ * by smaller local models that keep calling tools instead of converging.
125
+ * Bounds keep the setting a sane guard rail rather than an off switch.
126
+ */
127
+ export const MAX_TOOL_ROUNDS_DEFAULT = 50;
128
+ export const MAX_TOOL_ROUNDS_MIN = 1;
129
+ export const MAX_TOOL_ROUNDS_MAX = 1000;
120
130
  /**
121
131
  * Compaction tuning for providers whose conversation the daemon owns
122
132
  * (openai-compat). These set the provider-level defaults; the per-agent
@@ -165,6 +175,11 @@ export const ProviderOverrideSchema = z.object({
165
175
  * (openai-compat). Per-agent feature values win over these.
166
176
  */
167
177
  compaction: ProviderCompactionConfigSchema.optional(),
178
+ /**
179
+ * Max model→tool→model rounds per turn for daemon-hosted providers
180
+ * (openai-compat). Omitted = the built-in default (MAX_TOOL_ROUNDS_DEFAULT).
181
+ */
182
+ maxToolRounds: z.number().int().min(MAX_TOOL_ROUNDS_MIN).max(MAX_TOOL_ROUNDS_MAX).optional(),
168
183
  enabled: z.boolean().optional(),
169
184
  order: z.number().optional(),
170
185
  });
@@ -124,6 +124,19 @@ const MOCK_LOAD_TEST_MODES = [
124
124
  icon: "ShieldOff",
125
125
  colorTier: "dangerous",
126
126
  },
127
+ {
128
+ id: "dontAsk",
129
+ label: "Don't Ask",
130
+ description: "Runs without prompting — actions not pre-approved are denied",
131
+ icon: "ShieldCheck",
132
+ colorTier: "moderate",
133
+ // Dev-only mirror of Claude's guarded unattended mode so deterministic E2E
134
+ // specs can exercise the system-assigned surfaces: the unattended coercion
135
+ // target for mock schedule runs, and the locked mode badge on a live agent
136
+ // stuck in a non-user-selectable mode.
137
+ isUnattended: true,
138
+ userSelectable: false,
139
+ },
127
140
  ];
128
141
  const MOCK_SLOW_MODES = [
129
142
  {
@@ -7,5 +7,14 @@ export interface ToolCallDisplayModel {
7
7
  summary?: string;
8
8
  errorText?: string;
9
9
  }
10
+ /**
11
+ * Friendly display name for a bare tool identifier, used at every surface that
12
+ * shows a tool/action name without a full timeline item to run through
13
+ * {@link buildToolCallDisplayModel} (the visualizer's action labels, sub-agent
14
+ * activity rows). Strips the MCP/Otto namespace, consults the known-tool
15
+ * registry, then title-cases as a fallback — so "mcp__otto__spawn_task",
16
+ * "otto.spawn_task", and a bare "spawn_task" all render as "Spawn Task".
17
+ */
18
+ export declare function getToolDisplayName(name: string): string;
10
19
  export declare function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel;
11
20
  //# sourceMappingURL=tool-call-display.d.ts.map
@@ -1,4 +1,4 @@
1
- import { getOttoToolLeafName, isOttoToolName } from "./tool-name-normalization.js";
1
+ import { getMcpToolLeafName, getOttoToolLeafName } from "./tool-name-normalization.js";
2
2
  import { stripCwdPrefix } from "./path-utils.js";
3
3
  function isRecord(value) {
4
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -6,26 +6,71 @@ function isRecord(value) {
6
6
  function readString(value) {
7
7
  return typeof value === "string" && value.length > 0 ? value : undefined;
8
8
  }
9
+ // Curated display names — the registry of tools we explicitly "know about".
10
+ // Keyed by the lowercased leaf name (transport namespace already stripped).
11
+ //
12
+ // Only tools whose bare id does NOT title-case cleanly on its own need an entry
13
+ // here: lowercase compound names ("websearch") a splitter can't segment, or
14
+ // ones we want to word deliberately. Well-formed snake_case ("spawn_task") and
15
+ // camelCase ("WebSearch") names are handled by the algorithmic humanizer below
16
+ // and do NOT need listing.
17
+ //
18
+ // Anything not here falls through to the humanizer and still renders readably —
19
+ // so an unmapped tool shows up looking slightly generic (e.g. a stray provider
20
+ // tool), which is the cue to add it here. Extend this map to teach Otto a tool.
21
+ const KNOWN_TOOL_DISPLAY_NAMES = {
22
+ websearch: "Web Search",
23
+ web_search: "Web Search",
24
+ webfetch: "Web Fetch",
25
+ web_fetch: "Web Fetch",
26
+ todowrite: "Update Todos",
27
+ todoread: "Read Todos",
28
+ multiedit: "Multi Edit",
29
+ notebookedit: "Edit Notebook",
30
+ notebookread: "Read Notebook",
31
+ bashoutput: "Bash Output",
32
+ killshell: "Kill Shell",
33
+ exitplanmode: "Exit Plan Mode",
34
+ applypatch: "Apply Patch",
35
+ ls: "List Files",
36
+ };
37
+ // Split camelCase / PascalCase and separator-delimited identifiers into words,
38
+ // then Title-Case them: "WebSearch" -> "Web Search", "spawn_task" -> "Spawn
39
+ // Task", "HTTPServer" -> "HTTP Server".
40
+ function titleCaseToolId(value) {
41
+ return value
42
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
43
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
44
+ .replace(/[._-]+/g, " ")
45
+ .split(" ")
46
+ .filter((segment) => segment.length > 0)
47
+ .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
48
+ .join(" ");
49
+ }
9
50
  function humanizeToolName(name) {
10
51
  const trimmed = name.trim();
11
52
  if (!trimmed) {
12
53
  return name;
13
54
  }
14
- if (isOttoToolName(trimmed)) {
15
- const leaf = getOttoToolLeafName(trimmed);
16
- if (leaf) {
17
- return humanizeToolName(leaf);
18
- }
19
- }
20
- if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
21
- return trimmed;
55
+ // Strip the transport namespace first ("mcp__otto__spawn_task" ->
56
+ // "spawn_task", "otto.list_agents" -> "list_agents") so both the known-tool
57
+ // lookup and the fallback operate on the bare tool id.
58
+ const leaf = getMcpToolLeafName(trimmed) ?? getOttoToolLeafName(trimmed);
59
+ if (leaf) {
60
+ return humanizeToolName(leaf);
22
61
  }
23
- return trimmed
24
- .replace(/[._-]+/g, " ")
25
- .split(" ")
26
- .filter((segment) => segment.length > 0)
27
- .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
28
- .join(" ");
62
+ return KNOWN_TOOL_DISPLAY_NAMES[trimmed.toLowerCase()] ?? titleCaseToolId(trimmed);
63
+ }
64
+ /**
65
+ * Friendly display name for a bare tool identifier, used at every surface that
66
+ * shows a tool/action name without a full timeline item to run through
67
+ * {@link buildToolCallDisplayModel} (the visualizer's action labels, sub-agent
68
+ * activity rows). Strips the MCP/Otto namespace, consults the known-tool
69
+ * registry, then title-cases as a fallback — so "mcp__otto__spawn_task",
70
+ * "otto.spawn_task", and a bare "spawn_task" all render as "Spawn Task".
71
+ */
72
+ export function getToolDisplayName(name) {
73
+ return humanizeToolName(name);
29
74
  }
30
75
  function formatErrorText(error) {
31
76
  if (error === null || error === undefined) {
@@ -5,5 +5,21 @@ export declare function isSpeakToolName(name: string): boolean;
5
5
  export declare function isLikelyNamespacedToolName(name: string): boolean;
6
6
  export declare function isOttoToolName(name: string): boolean;
7
7
  export declare function getOttoToolLeafName(name: string): string | null;
8
+ /**
9
+ * Strip a leading MCP namespace for DISPLAY, returning the bare tool id.
10
+ *
11
+ * Tools hosted over MCP arrive namespaced as `mcp__<server>__<tool>` (Claude
12
+ * Code format). The `mcp__<server>__` part is transport plumbing that means
13
+ * nothing to a reader — "Create Issue", not "mcp__linear__create_issue". This
14
+ * generalizes {@link getOttoToolLeafName} to ANY server so every MCP tool reads
15
+ * cleanly, not just Otto's own.
16
+ *
17
+ * Returns `null` when the name carries no `mcp__…__` namespace, so callers keep
18
+ * plain tool names (`Read`, `bash`) and dotted/other forms untouched. Case is
19
+ * preserved — MCP tool ids are already snake_case and callers humanize after.
20
+ * `speak` is never treated as namespaced (it renders as a chat bubble, not an
21
+ * action row).
22
+ */
23
+ export declare function getMcpToolLeafName(name: string): string | null;
8
24
  export declare function isLikelyExternalToolName(name: string): boolean;
9
25
  //# sourceMappingURL=tool-name-normalization.d.ts.map
@@ -69,6 +69,32 @@ export function getOttoToolLeafName(name) {
69
69
  }
70
70
  return null;
71
71
  }
72
+ /**
73
+ * Strip a leading MCP namespace for DISPLAY, returning the bare tool id.
74
+ *
75
+ * Tools hosted over MCP arrive namespaced as `mcp__<server>__<tool>` (Claude
76
+ * Code format). The `mcp__<server>__` part is transport plumbing that means
77
+ * nothing to a reader — "Create Issue", not "mcp__linear__create_issue". This
78
+ * generalizes {@link getOttoToolLeafName} to ANY server so every MCP tool reads
79
+ * cleanly, not just Otto's own.
80
+ *
81
+ * Returns `null` when the name carries no `mcp__…__` namespace, so callers keep
82
+ * plain tool names (`Read`, `bash`) and dotted/other forms untouched. Case is
83
+ * preserved — MCP tool ids are already snake_case and callers humanize after.
84
+ * `speak` is never treated as namespaced (it renders as a chat bubble, not an
85
+ * action row).
86
+ */
87
+ export function getMcpToolLeafName(name) {
88
+ const trimmed = name.trim();
89
+ if (!trimmed || isSpeakToolName(trimmed) || !trimmed.includes("__")) {
90
+ return null;
91
+ }
92
+ const segments = trimmed.split("__").filter((segment) => segment.length > 0);
93
+ if (segments.length >= 3 && segments[0].toLowerCase() === "mcp") {
94
+ return segments.slice(2).join("__");
95
+ }
96
+ return null;
97
+ }
72
98
  export function isLikelyExternalToolName(name) {
73
99
  const normalized = normalizeToolName(name);
74
100
  if (!normalized) {