Skip to content

Commit d6d8a64

Browse files
committed
mothership agent-cli: round two of exploration-run fixes
- CLI inventory (generator): every v2 operation maps to its command through deriveCommandPath (168 commands now carry a response shape, was 61 — blocks get had none); request-body shapes two levels deep for JSON-valued flags (select-column options, workflow groups, views, variables, operations apply); enum choices on options; union variants show their discriminator. - sink: the not-written notice leads the inline output so the budget cannot cut it (| to-sandbox read as a silent no-op when the sandbox was not up). - files grep: no doubled slash on root-folder paths. - embed: the missing-file message says to pass @path instead of implying the file does not exist. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8
1 parent 6fddc12 commit d6d8a64

5 files changed

Lines changed: 108 additions & 19 deletions

File tree

apps/sim/lib/mothership/agent-cli/engines/files-grep.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export const filesGrepCommand: AgentCliEngine = {
8686
})
8787
)
8888
for (const { file, text } of texts) {
89-
const label = file.folderPath ? `${file.folderPath}/${file.name}` : file.name
89+
const folder = (file.folderPath ?? '').replace(/\/+$/, '')
90+
const label = folder ? `${folder}/${file.name}` : `/${file.name}`
9091
if (matches(file.name) && out.length < MAX_MATCHES) out.push(`${label}: name matches`)
9192
if (text === null) {
9293
unreadable++

apps/sim/lib/mothership/agent-cli/sink.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ export async function applySink(
3131
if (written.outcome === 'no-session') {
3232
return {
3333
...result,
34-
stdout: `${result.stdout}\n[outputFile not written: your machine is not booted yet — run any run_code first. Output returned inline instead]`,
34+
// The notice leads: appended, it was the first thing the output budget cut.
35+
stdout: `[NOT written to ${sink.path}: your machine is not booted yet — run any run_code first, then re-run this command. The output follows inline instead.]\n${result.stdout}`,
3536
}
3637
}
3738
return {
3839
...result,
39-
stdout: `${result.stdout}\n[outputFile write failed output returned inline instead]`,
40+
stdout: `[NOT written to ${sink.path}: the write to your machine failed. The output follows inline instead.]\n${result.stdout}`,
4041
}
4142
}

packages/sim-cli/scripts/print-command-inventory.ts

Lines changed: 101 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { Command } from 'commander'
1616
import { CLI_CONTRACT } from '../src/contract/commands'
1717
import { V2_OPERATIONS } from '../src/generated/v2-api'
1818
import { buildProgram } from '../src/program'
19+
import { deriveCommandPath } from '../src/runtime/derive'
1920

2021
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
2122
const HELP_COMMAND = 'help'
@@ -35,6 +36,8 @@ interface InventoryOption {
3536
required: boolean
3637
description: string
3738
defaultValue?: string
39+
/** Allowed values when the option is an enum (commander `choices`). */
40+
choices?: string[]
3841
}
3942

4043
interface InventoryShapeField {
@@ -49,6 +52,11 @@ interface InventoryCommand {
4952
options: InventoryOption[]
5053
/** Top-level fields of the JSON response's `data`, when the operation is known. */
5154
shape?: InventoryShapeField[]
55+
/**
56+
* Fields of the JSON request body, two levels deep, for commands that take a
57+
* `<json|@file>` option — the nested payloads an agent otherwise learns from errors.
58+
*/
59+
body?: InventoryShapeField[]
5260
}
5361

5462
function isHiddenCommand(command: Command): boolean {
@@ -67,8 +75,15 @@ function collectLeaves(command: Command, prefix: string[]): { path: string[]; co
6775
return children.flatMap((child) => collectLeaves(child, [...prefix, child.name()]))
6876
}
6977

70-
/** Command path → v2 operation name, through the contract's declared command strings. */
78+
/**
79+
* Command path → v2 operation name. Every operation names its command the way the
80+
* program builder does (`deriveCommandPath`); a contract entry's explicit `command`
81+
* string overrides that, exactly as it does when the program is built.
82+
*/
7183
const OPERATION_BY_PATH = new Map<string, string>()
84+
for (const operation of Object.keys(V2_OPERATIONS) as (keyof typeof V2_OPERATIONS)[]) {
85+
OPERATION_BY_PATH.set(deriveCommandPath(operation).join(' '), operation)
86+
}
7287
for (const [operation, spec] of Object.entries(CLI_CONTRACT)) {
7388
if (spec && 'command' in spec && typeof spec.command === 'string') {
7489
OPERATION_BY_PATH.set(spec.command, operation)
@@ -85,14 +100,19 @@ type JsonSchema = {
85100
allOf?: JsonSchema[]
86101
nullable?: boolean
87102
enum?: unknown[]
103+
const?: unknown
104+
required?: string[]
88105
}
89106

90107
interface OpenApiDoc {
91108
paths: Record<
92109
string,
93110
Record<
94111
string,
95-
{ responses?: Record<string, { content?: Record<string, { schema?: JsonSchema }> }> }
112+
{
113+
responses?: Record<string, { content?: Record<string, { schema?: JsonSchema }> }>
114+
requestBody?: { content?: Record<string, { schema?: JsonSchema }> }
115+
}
96116
>
97117
>
98118
components?: { schemas?: Record<string, JsonSchema> }
@@ -114,19 +134,44 @@ function resolveRef(doc: OpenApiDoc, schema: JsonSchema | undefined): JsonSchema
114134
return current
115135
}
116136

117-
function typeLabel(doc: OpenApiDoc, schema: JsonSchema | undefined): string {
137+
/**
138+
* A compact type for one schema. `depth` is how many object levels render their fields
139+
* with types (`{name:string,options:{id,name}[]}`); below it an object lists key names
140+
* only, as the response shapes always have.
141+
*/
142+
function typeLabel(
143+
doc: OpenApiDoc,
144+
schema: JsonSchema | undefined,
145+
depth = 0,
146+
enums: 'full' | 'brief' = 'full'
147+
): string {
118148
const resolved = resolveRef(doc, schema)
119149
if (!resolved) return 'unknown'
120-
if (resolved.enum) return resolved.enum.map((v) => JSON.stringify(v)).join('|')
150+
if (resolved.const !== undefined) return JSON.stringify(resolved.const)
151+
if (resolved.enum) {
152+
// A response is read, not written: one value and the count is enough to recognise
153+
// the field; a request body needs every legal value.
154+
if (enums === 'brief' && resolved.enum.length > 2) {
155+
return `${JSON.stringify(resolved.enum[0])}|…(${resolved.enum.length})`
156+
}
157+
return resolved.enum.map((v) => JSON.stringify(v)).join('|')
158+
}
121159
const variants = resolved.anyOf ?? resolved.oneOf
122-
if (variants) return variants.map((v) => typeLabel(doc, v)).join('|')
160+
if (variants) return variants.map((v) => typeLabel(doc, v, depth, enums)).join('|')
123161
const type = Array.isArray(resolved.type) ? resolved.type.join('|') : resolved.type
124-
if (type === 'array') return `${typeLabel(doc, resolved.items)}[]`
162+
if (type === 'array') return `${typeLabel(doc, resolved.items, depth, enums)}[]`
125163
if (type === 'object' || resolved.properties) {
126-
const keys = Object.keys(resolved.properties ?? {})
127-
return keys.length > 0
128-
? `{${keys.slice(0, 8).join(',')}${keys.length > 8 ? ',…' : ''}}`
129-
: 'object'
164+
const props = resolved.properties ?? {}
165+
const keys = Object.keys(props)
166+
if (keys.length === 0) return 'object'
167+
if (depth > 0) {
168+
const required = new Set(resolved.required ?? [])
169+
return `{${keys
170+
.slice(0, 12)
171+
.map((k) => `${k}${required.has(k) ? '' : '?'}:${typeLabel(doc, props[k], depth - 1)}`)
172+
.join(',')}${keys.length > 12 ? ',…' : ''}}`
173+
}
174+
return `{${keys.slice(0, 8).join(',')}${keys.length > 8 ? ',…' : ''}}`
130175
}
131176
return type ?? 'unknown'
132177
}
@@ -145,15 +190,53 @@ function responseShape(operation: string): InventoryShapeField[] | undefined {
145190
if (!props) {
146191
const items = resolveRef(doc, data.items)
147192
if (items?.properties) {
148-
return [{ name: '[]', type: typeLabel(doc, items) }]
193+
return [{ name: '[]', type: typeLabel(doc, items, 0, 'brief') }]
149194
}
150195
return undefined
151196
}
152-
return Object.entries(props).map(([name, s]) => ({ name, type: typeLabel(doc, s) }))
197+
return Object.entries(props).map(([name, s]) => ({
198+
name,
199+
type: typeLabel(doc, s, 0, 'brief'),
200+
}))
201+
}
202+
return undefined
203+
}
204+
205+
/** The JSON request body's fields, two levels deep — only asked for commands with a `<json|@file>` option. */
206+
function requestShape(
207+
operation: string,
208+
jsonFields: Set<string>
209+
): InventoryShapeField[] | undefined {
210+
const op = V2_OPERATIONS[operation as keyof typeof V2_OPERATIONS]
211+
if (!op) return undefined
212+
const docPath = op.path.replace(/\[([^\]]+)\]/g, '{$1}')
213+
for (const doc of OPENAPI_DOCS) {
214+
const entry = doc.paths[docPath]?.[op.method.toLowerCase()]
215+
const schema = entry?.requestBody?.content?.['application/json']?.schema
216+
const resolved = resolveRef(doc, schema)
217+
if (!resolved?.properties) continue
218+
const required = new Set(resolved.required ?? [])
219+
const entries = Object.entries(resolved.properties)
220+
// The CLI injects the workspace; the model never writes it.
221+
.filter(([name]) => name !== 'workspaceId')
222+
// Scalar flags are already on the signature; the card carries the JSON-valued
223+
// fields only (the ones whose shape is otherwise learned from error messages).
224+
const nested = entries.filter(([name]) => jsonFields.has(name))
225+
return (nested.length > 0 ? nested : entries).map(([name, s]) => ({
226+
name: required.has(name) ? name : `${name}?`,
227+
type: capType(typeLabel(doc, s, 2)),
228+
}))
153229
}
154230
return undefined
155231
}
156232

233+
const MAX_BODY_TYPE_CHARS = 220
234+
235+
/** A recursive grammar (the row predicate) expands past what a card line can carry. */
236+
function capType(label: string): string {
237+
return label.length > MAX_BODY_TYPE_CHARS ? `${label.slice(0, MAX_BODY_TYPE_CHARS)}…` : label
238+
}
239+
157240
const program = buildProgram()
158241
const inventory: InventoryCommand[] = collectLeaves(program, []).map(
159242
({ path: cmdPath, command }) => {
@@ -172,15 +255,21 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map(
172255
required: option.mandatory,
173256
description: option.description,
174257
...(option.defaultValue !== undefined ? { defaultValue: String(option.defaultValue) } : {}),
258+
...(option.argChoices ? { choices: option.argChoices } : {}),
175259
}))
176260
const operation = OPERATION_BY_PATH.get(cmdPath.join(' '))
177261
const shape = operation ? responseShape(operation) : undefined
262+
const jsonFields = new Set(
263+
options.filter((option) => /<json\|@file>/.test(option.flags)).map((option) => option.name)
264+
)
265+
const body = operation && jsonFields.size > 0 ? requestShape(operation, jsonFields) : undefined
178266
return {
179267
path: cmdPath,
180268
description: command.description(),
181269
args,
182270
options,
183271
...(shape ? { shape } : {}),
272+
...(body ? { body } : {}),
184273
}
185274
}
186275
)

packages/sim-cli/src/transfer/embedded-files.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@ describe('embedded positional file arguments', () => {
3232

3333
it('refuses a path the host did not pre-read, telling the caller how to provide it', async () => {
3434
await embedStore.run(embedded(), async () => {
35-
await expect(localFile('@missing.csv')).rejects.toThrow(
36-
'No file "missing.csv" on your machine'
37-
)
35+
await expect(localFile('@missing.csv')).rejects.toThrow('use @missing.csv')
3836
})
3937
})
4038
})

packages/sim-cli/src/transfer/local-file.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function embeddedFileContent(embedded: EmbedContext, path: string): strin
1919
const content = embedded.fileArguments?.[key]
2020
if (content !== undefined) return content
2121
throw new SimApiError(
22-
`No file "${key}" on your machine — write it first (run_code or | to-sandbox), then pass it as @${key}`,
22+
`Files on your machine are passed as @path: use @${key}. (If it does not exist yet, write it with run_code or | to-sandbox first.)`,
2323
0
2424
)
2525
}

0 commit comments

Comments
 (0)