-
Notifications
You must be signed in to change notification settings - Fork 0
feat(sdk): first-class headless adapter per agent CLI (#141) #382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| /** | ||
| * First-class headless adapter contract (flows#141). | ||
| * | ||
| * Every supported agent CLI ships one implementation. `worker-cli.ts` and | ||
| * `cli/check.ts` dispatch through the registry in `./index.ts` — never string | ||
| * formatting against per-CLI knowledge inline. Adding a new CLI is one new | ||
| * file that implements this interface. | ||
| * | ||
| * Behavior-preserving migration: the exported functions in `cli-adapter.ts` | ||
| * (`agentExecution`, `llmExecution`, `authenticationProbe`, | ||
| * `modelReadinessProbe`, `adapterIdentification`) now delegate to the | ||
| * registered adapter for the resolved kind, so callers don't change. | ||
| */ | ||
|
|
||
| export interface CliInvocation { | ||
| args: string[]; | ||
| timeoutMs: number; | ||
| /** Set only for wrapper readiness probes; raw providers receive a model flag. */ | ||
| modelEnv?: string; | ||
| } | ||
|
|
||
| export interface CliAdapterIdentification { | ||
| invocation: CliInvocation; | ||
| expectedStdout?: string; | ||
| } | ||
|
|
||
| /** | ||
| * The declared, per-CLI headless contract. Members intentionally mirror the | ||
| * function-scoped predecessors in `cli-adapter.ts` so migration is mechanical. | ||
| */ | ||
| export interface HeadlessAdapter { | ||
| /** Identity of this adapter — matches CliAdapterKind for registry keys. */ | ||
| readonly kind: string; | ||
|
|
||
| /** Shape-check invocation before classifying an auth failure. */ | ||
| buildIdentification(): CliAdapterIdentification; | ||
|
|
||
| /** Non-interactive auth-status probe. */ | ||
| buildAuthProbe(): CliInvocation; | ||
|
|
||
| /** | ||
| * Real, noninteractive model round-trip. Providers hand the model via a | ||
| * native flag; wrappers hand it via the explicitly identified environment | ||
| * contract on `modelEnv`. | ||
| */ | ||
| buildModelReadinessProbe(model: string): CliInvocation; | ||
|
|
||
| /** Agent-step worker argv for an instruction under an optional model. */ | ||
| buildAgentInvocation(instruction: string, model?: string): CliInvocation; | ||
|
|
||
| /** LLM-step worker argv for a prompt under an optional model. */ | ||
| buildLlmInvocation(prompt: string, model?: string): CliInvocation; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import type { | ||
| CliAdapterIdentification, | ||
| CliInvocation, | ||
| HeadlessAdapter, | ||
| } from './base.js'; | ||
|
|
||
| const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.'; | ||
|
|
||
| /** Claude Code CLI headless contract. Wire and behavior identical to the | ||
| * pre-#141 inline shape in `cli-adapter.ts` — only the packaging changed. */ | ||
| export const claudeAdapter: HeadlessAdapter = { | ||
| kind: 'claude', | ||
|
|
||
| buildIdentification(): CliAdapterIdentification { | ||
| return { invocation: { args: ['auth', 'status', '--help'], timeoutMs: 10_000 } }; | ||
| }, | ||
|
|
||
| buildAuthProbe(): CliInvocation { | ||
| return { args: ['auth', 'status'], timeoutMs: 10_000 }; | ||
| }, | ||
|
|
||
| buildModelReadinessProbe(model: string): CliInvocation { | ||
| return { | ||
| args: [ | ||
| '-p', '--model', model, '--tools', '', '--no-session-persistence', | ||
| MODEL_PROBE_PROMPT, | ||
| ], | ||
| timeoutMs: 60_000, | ||
| }; | ||
| }, | ||
|
|
||
| buildAgentInvocation(instruction: string, model?: string): CliInvocation { | ||
| return { | ||
| args: [ | ||
| '-p', | ||
| // Symmetric to codex's --dangerously-bypass-approvals-and-sandbox: in | ||
| // agent mode the flow explicitly delegates writes. Without this | ||
| // claude's headless mode prompts for tool approval, gets no TTY, | ||
| // and completes "successfully" without touching files. | ||
| '--dangerously-skip-permissions', | ||
| ...(model === undefined ? [] : ['--model', model]), | ||
| instruction, | ||
| ], | ||
| timeoutMs: 0, | ||
| }; | ||
| }, | ||
|
|
||
| buildLlmInvocation(prompt: string, model?: string): CliInvocation { | ||
| return { | ||
| args: ['-p', '--tools', '', '--no-session-persistence', | ||
| ...(model === undefined ? [] : ['--model', model]), prompt], | ||
| timeoutMs: 0, | ||
| }; | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import type { | ||
| CliAdapterIdentification, | ||
| CliInvocation, | ||
| HeadlessAdapter, | ||
| } from './base.js'; | ||
|
|
||
| const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.'; | ||
|
|
||
| /** OpenAI Codex CLI headless contract. Behavior identical to the pre-#141 | ||
| * inline shape in `cli-adapter.ts` — only the packaging changed. */ | ||
| export const codexAdapter: HeadlessAdapter = { | ||
| kind: 'codex', | ||
|
|
||
| buildIdentification(): CliAdapterIdentification { | ||
| return { invocation: { args: ['login', 'status', '--help'], timeoutMs: 10_000 } }; | ||
| }, | ||
|
|
||
| buildAuthProbe(): CliInvocation { | ||
| return { args: ['login', 'status'], timeoutMs: 10_000 }; | ||
| }, | ||
|
|
||
| buildModelReadinessProbe(model: string): CliInvocation { | ||
| return { | ||
| args: [ | ||
| 'exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check', | ||
| '--model', model, MODEL_PROBE_PROMPT, | ||
| ], | ||
| timeoutMs: 60_000, | ||
| }; | ||
| }, | ||
|
|
||
| buildAgentInvocation(instruction: string, model?: string): CliInvocation { | ||
| return { | ||
| args: [ | ||
| 'exec', '--ephemeral', '--skip-git-repo-check', | ||
| // Agent-mode is where the flow explicitly delegates code changes to | ||
| // the CLI. Without this flag codex prompts for approval on every | ||
| // write, gets nothing (no TTY), and completes "successfully" without | ||
| // touching files — the dogfood no-op failure mode. LLM-mode stays | ||
| // read-only and does NOT get the bypass. | ||
| '--dangerously-bypass-approvals-and-sandbox', | ||
| ...(model === undefined ? [] : ['--model', model]), | ||
| instruction, | ||
| ], | ||
| timeoutMs: 0, | ||
| }; | ||
| }, | ||
|
|
||
| buildLlmInvocation(prompt: string, model?: string): CliInvocation { | ||
| return { | ||
| args: ['exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check', | ||
| ...(model === undefined ? [] : ['--model', model]), prompt], | ||
| timeoutMs: 0, | ||
| }; | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { basename } from 'node:path'; | ||
|
|
||
| import { claudeAdapter } from './claude.js'; | ||
| import { codexAdapter } from './codex.js'; | ||
| import { wrapperAdapter } from './wrapper.js'; | ||
| import type { HeadlessAdapter } from './base.js'; | ||
|
|
||
| export type CliAdapterKind = 'claude' | 'codex' | 'relayflows-wrapper-v1'; | ||
|
|
||
| /** The registered adapter set. Adding a CLI is adding one file + one entry. */ | ||
| const ADAPTERS: Record<CliAdapterKind, HeadlessAdapter> = { | ||
| claude: claudeAdapter, | ||
| codex: codexAdapter, | ||
| 'relayflows-wrapper-v1': wrapperAdapter, | ||
| }; | ||
|
|
||
| /** Select the adapter for the resolved executable basename. */ | ||
| export function resolveAdapter(executable: string): HeadlessAdapter { | ||
| const name = basename(executable).replace(/\.exe$/i, ''); | ||
| if (name === 'claude') return ADAPTERS.claude; | ||
| if (name === 'codex') return ADAPTERS.codex; | ||
| return ADAPTERS['relayflows-wrapper-v1']; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a new adapter is added to Prompt for AI agents |
||
| } | ||
|
|
||
| /** Backward-compatible kind lookup for callers still speaking string-tag lang. */ | ||
| export function resolveAdapterKind(executable: string): CliAdapterKind { | ||
| return resolveAdapter(executable).kind as CliAdapterKind; | ||
| } | ||
|
|
||
| /** Registered adapters by kind (read-only view for tests and diagnostics). */ | ||
| export function registeredAdapters(): Readonly<Record<CliAdapterKind, HeadlessAdapter>> { | ||
| return ADAPTERS; | ||
| } | ||
|
|
||
| export { claudeAdapter, codexAdapter, wrapperAdapter }; | ||
| export type { HeadlessAdapter, CliInvocation, CliAdapterIdentification } from './base.js'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import type { | ||
| CliAdapterIdentification, | ||
| CliInvocation, | ||
| HeadlessAdapter, | ||
| } from './base.js'; | ||
|
|
||
| /** | ||
| * `relayflows-adapter-v1` identifies a per-flow wrapper script — the pre-#141 | ||
| * per-flow shim shape. The wrapper answers a probe with a known token; agent | ||
| * and llm execution flow through `runWrapperSession`, so this adapter refuses | ||
| * to construct a direct-argv invocation for them. | ||
| */ | ||
| export const WRAPPER_IDENTIFY_ARG = '--relayflows-adapter-v1'; | ||
| export const WRAPPER_IDENTIFY_TOKEN = 'relayflows-agent-cli-v1'; | ||
| export const WRAPPER_EXECUTE_TOKEN = 'relayflows-agent-cli-v1-execute'; | ||
|
|
||
| export const wrapperAdapter: HeadlessAdapter = { | ||
| kind: 'relayflows-wrapper-v1', | ||
|
|
||
| buildIdentification(): CliAdapterIdentification { | ||
| return { | ||
| invocation: { args: [WRAPPER_IDENTIFY_ARG], timeoutMs: 10_000 }, | ||
| expectedStdout: WRAPPER_IDENTIFY_TOKEN, | ||
| }; | ||
| }, | ||
|
|
||
| buildAuthProbe(): CliInvocation { | ||
| return { args: ['auth', 'status'], timeoutMs: 10_000 }; | ||
| }, | ||
|
|
||
| buildModelReadinessProbe(model: string): CliInvocation { | ||
| return { args: ['auth', 'status'], timeoutMs: 60_000, modelEnv: model }; | ||
| }, | ||
|
|
||
| buildAgentInvocation(): CliInvocation { | ||
| throw new Error('custom wrapper execution requires the runAgentCli same-process session'); | ||
| }, | ||
|
|
||
| buildLlmInvocation(): CliInvocation { | ||
| throw new Error('custom wrapper execution requires the same-process session'); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
kindis typedstringbut must exactly match the registry key inadapters/index.tsfor legacy dispatch to work, and nothing enforces that.resolveAdapterKind()(index.ts:27) returnsresolveAdapter(executable).kind as CliAdapterKind, and every legacy helper incli-adapter.ts(agentExecution,authenticationProbe, …) indexesregisteredAdapters()[kind]with that value. A typo in a new adapter'skindpasses typecheck, then mislabels the kind and throws (registeredAdapters()[kind]returns undefined) at runtime. SinceCliAdapterKindis a type-only import, typekind: CliAdapterKind(imported from./index.js) to let the compiler enforce that the adapter's declared identity is a valid registry key; alternatively derive the kind from the record key instead of duplicating it on the adapter.Prompt for AI agents