Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions default/skills/alice/references/collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Use `conversation`, not Inbox, for ordinary coworker communication.
alice peer list

# Recruit a fresh Session at that Workspace for new work.
alice conversation ask --ws-id <workspaceId> \
alice conversation create --ws-id <workspaceId> \
--prompt 'Investigate this bounded question and report back.'

# Continue one exact attributable product Session.
Expand All @@ -46,7 +46,7 @@ alice conversation ask --inbox-id <entryId> \
--prompt 'What did you send, and what should I inspect first?' --await

# Recruit a fresh Session in a Harness default Workspace.
alice conversation ask --harness autoquant \
alice conversation create --harness autoquant \
--prompt 'Start a new quantitative research assignment.'
```

Expand All @@ -56,6 +56,18 @@ requires the explicitly initialized AutoQuant default Workspace and never
creates or guesses one. Both launch a fresh product Session in the resolved
desk; use the returned `resumeId` for later continuation.

`create` always creates a new Session and sends its first prompt. `ask --resume-id`
continues an existing Session. Keep `resumeId` as the coworker's address and
`taskId` as one turn's execution handle.

Both commands accept optional `--credential <vault-slug>` or
`--credential-source native` (mutually exclusive), `--model <id>`, and
`--effort <level>`. Never pass API keys. New Sessions inherit Workspace headless
preferences; existing Sessions retain their own binding for omitted fields.
Changing credential drops inherited model/effort from the previous credential.
Explicit changes to an existing Session persist for later turns and require it
to be idle. Its Agent runtime cannot change. `--agent` selects only a new worker.

Prompts are ordinary coworker messages. Add `--reconstruct` only when the task
explicitly requires a fresh worker to reconstruct missing historical intent.
Provenance may still report `resolution.mode: reconstructed` without changing
Expand Down
2 changes: 1 addition & 1 deletion default/skills/delegate-autoquant/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ correct handoff.
Use the `alice` collaboration surface:

```bash
alice conversation ask --harness autoquant --await --prompt '
alice conversation create --harness autoquant --await --prompt '
Research question: <question>
Decision this supports: <decision>
Caller-owned scope and constraints: <assets, direction, horizon, cadence, benchmark, costs, limits>
Expand Down
6 changes: 3 additions & 3 deletions default/skills/workspace-manager/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ alice peer path --id <workspaceId>
alice peer sessions --id <workspaceId>
alice conversation ask --resume-id <resumeId> --prompt "..." --await
# Recruit a fresh coworker for new work:
alice conversation ask --ws-id <workspaceId> --prompt "..."
alice conversation create --ws-id <workspaceId> --prompt "..."
# Explicit historical reconstruction when no attributable Session exists:
alice conversation ask --ws-id <workspaceId> --prompt "..." --reconstruct --await
alice conversation create --ws-id <workspaceId> --prompt "..." --reconstruct --await
```

This distinction is not cosmetic. `--resume-id` continues the exact coworker
Expand All @@ -54,7 +54,7 @@ instructions.
- Prefer asking an attributable `resumeId` when one is known. Recent Session
titles in `peer list` are hints; use `peer sessions --id` only for the few
relevant desks to resolve the exact identity. Otherwise recruit a worker from
the relevant Workspace with `conversation ask --ws-id`. Add `--reconstruct`
the relevant Workspace with `conversation create --ws-id`. Add `--reconstruct`
only for missing historical intent and never claim the fresh worker carries
the original coworker's memory.
- Use `--await` for a short answer needed now. For delegated work or several
Expand Down
16 changes: 16 additions & 0 deletions docs/conversation-provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -807,3 +807,19 @@ of stderr for failed/interrupted tasks, with `stderrTruncated` indicating a
clipped log. Existing tasks can recover this diagnostic from their log file;
missing logs still leave an exit/signal or generic failure explanation. Logs
remain diagnostics, never assistant replies.


### Explicit creation and follow-up selection

`alice conversation create --ws-id <id> | --harness <name> --prompt <text>`
creates a new Session and dispatches its first turn. `conversation ask
--resume-id <id>` continues an existing Session; author addressing remains on
`ask`. Legacy `ask --ws-id/--harness` remains supported for copied Skills.

Both accept optional `credential` (vault slug), `credentialSource: native`,
`model`, and `effort`. Credential forms are mutually exclusive. Creation merges
with headless Workspace preferences. Exact follow-up patches only the Session's
binding under the execution claim: omitted fields retain that binding, changing
credential discards inherited model/effort, and explicit fields persist for
subsequent turns. Busy Sessions reject before editing the binding. Runtime
identity cannot change. No new persisted format is introduced.
13 changes: 13 additions & 0 deletions docs/model-semantics-and-runtime-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,3 +585,16 @@ namespaces that many remote containers cannot create.
Do not implement this by rewriting global user configuration. Native runtime
enterprise policies and OS permissions remain authoritative. UTA still owns
trading permissions; these launch settings do not change its trading mode.


### CLI conversation selection

`conversation create` accepts credential/model/effort overrides for a new
Session; `conversation ask` accepts the same optional dimensions for an idle
existing Session. Credential is a vault slug or explicit native access, never
secret material. Follow-up edits patch the stored binding under the headless
execution claim and do not consult Workspace defaults. Changing credential
clears inherited model/effort; omitted fields otherwise retain the Session's
selection. Runtime identity remains fixed. Web paused-Session editing and CLI
selection both resolve through `createSessionRuntimeBinding` and persist via
`replaceRuntimeBinding`.
2 changes: 2 additions & 0 deletions src/core/workspace-tool-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
*/

import type { Tool } from 'ai'
import type { SessionRuntimeSelection } from '../workspaces/session-runtime-binding.js'
import type { IInboxStore, InboxEntry, InboxOrigin } from './inbox-store.js'
import type { IEntityStore } from './entity-store.js'
import type { IProvenanceStore } from './provenance-store.js'
Expand Down Expand Up @@ -153,6 +154,7 @@ export interface WorkspaceConversationControl {
readonly timeoutMs?: number
readonly target: WorkspaceConversationTarget
readonly agent?: string
readonly selection?: SessionRuntimeSelection
/** Add the artifact-reconstruction preamble when a fresh fallback worker is
* required. Provenance may still resolve as reconstructed when this is
* false; prompt semantics and attribution are deliberately independent. */
Expand Down
1 change: 1 addition & 0 deletions src/server/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ const BASE_EXPORTS: Record<string, CliExport> = {
sessions: 'workspace_sessions',
},
conversation: {
create: 'conversation_create',
ask: 'conversation_ask',
await: 'conversation_await',
collect: 'conversation_collect',
Expand Down
34 changes: 34 additions & 0 deletions src/tool/conversation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createMemoryInboxStore } from '../core/inbox-store.js'
import type { WorkspaceToolContext } from '../core/workspace-tool-center.js'
import {
conversationCreateFactory,
conversationAskFactory,
conversationAwaitFactory,
conversationCollectFactory,
Expand Down Expand Up @@ -378,3 +379,36 @@ describe('conversation diagnostics projection', () => {
expect(taskProjection({ ...completedTask, error: 'warning' }, 'summary')).not.toHaveProperty('error')
})
})


describe('conversation creation and selection', () => {
it('creates in a Harness with the complete explicit runtime selection', async () => {
const ask = vi.fn(async () => ({ status: 'dispatched', taskId: 't', resumeId: 'r', workspaceId: 'w', workspace: 'w', agent: 'codex', resolution: { mode: 'reconstructed' } }))
const tool = conversationCreateFactory.build(context({ conversation: { ask, read: vi.fn() } as never }))
await run(tool, { harness: 'autoquant', prompt: 'work', agent: 'codex', credentialSource: 'native', model: 'custom-model', effort: 'medium' })
expect(ask).toHaveBeenCalledWith(expect.objectContaining({
target: { kind: 'harness', harness: 'autoquant' }, agent: 'codex',
selection: { credentialSource: 'native', model: 'custom-model', reasoningEffort: 'medium' },
}))
})
it('rejects conflicting authentication without dispatching', async () => {
const ask = vi.fn()
const tool = conversationCreateFactory.build(context({ conversation: { ask, read: vi.fn() } }))
expect(await run(tool, { wsId: 'w', prompt: 'work', credential: 'saved', credentialSource: 'native' })).toMatchObject({ ok: false })
expect(ask).not.toHaveBeenCalled()
})
it('forwards a partial follow-up selection without inventing defaults', async () => {
const ask = vi.fn(async () => ({ status: 'unavailable', resolution: { mode: 'unavailable', reason: 'missing' } }))
await run(conversationAskFactory.build(context({ conversation: { ask, read: vi.fn() } as never })), { resumeId: 'r', prompt: 'continue', effort: 'high' })
expect(ask).toHaveBeenCalledWith(expect.objectContaining({ selection: { reasoningEffort: 'high' }, target: { kind: 'resume', resumeId: 'r' } }))
})
})


it('creation target errors only suggest creation addresses', async () => {
const ask = vi.fn()
const tool = conversationCreateFactory.build(context({ conversation: { ask, read: vi.fn() } }))
expect(await run(tool, { prompt: 'work' })).toMatchObject({ ok: false, error: expect.stringContaining('exactly one target: --ws-id or --harness') })
expect(await run(tool, { prompt: 'work', wsId: 'w', harness: 'chat' })).toMatchObject({ ok: false })
expect(ask).not.toHaveBeenCalled()
})
56 changes: 50 additions & 6 deletions src/tool/conversation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { headlessFailureSummary } from '../workspaces/headless-failure.js'
import { tool } from 'ai'
import { MODEL_REASONING_EFFORTS } from '../ai-providers/model-semantics.js'
import type { SessionRuntimeSelection } from '../workspaces/session-runtime-binding.js'
import { z } from 'zod'

import type {
Expand All @@ -17,9 +19,16 @@ const MAX_TIMEOUT_MS = 2_147_478_647
const MAX_PROMPT_CHARS = 16_000
const AWAIT_POLL_MS = 250

const conversationSelectionShape = {
credential: z.string().min(1).optional().describe('OpenAlice vault credential slug, never an API key. Omit to retain the Session or Workspace selection.'),
credentialSource: z.literal('native').optional().describe('Use the runtime own authentication; mutually exclusive with credential.'),
model: z.string().min(1).optional().describe('Optional model id; custom model ids are accepted.'),
effort: z.enum(MODEL_REASONING_EFFORTS).optional().describe('Optional reasoning effort.'),
}

export const conversationAskCommonShape = {
prompt: z.string().trim().min(1).max(MAX_PROMPT_CHARS)
.describe('Question for the responsible Session or reconstructing worker.'),
.describe('First task for a new Session, or a follow-up message for an existing Session.'),
agent: z.string().min(1).optional()
.describe('Optional runtime for reconstructed/fresh work only; exact Session runtime cannot be overridden.'),
timeoutMs: z.coerce.number().int().positive().max(MAX_TIMEOUT_MS).optional()
Expand Down Expand Up @@ -85,6 +94,7 @@ export async function askWorkspaceConversation(
target: WorkspaceConversationTarget
subject?: HeadlessInquirySubject
agent?: string
selection?: SessionRuntimeSelection
timeoutMs?: number
await?: boolean
reconstruct?: boolean
Expand All @@ -97,6 +107,7 @@ export async function askWorkspaceConversation(
const result = await ctx.conversation.ask({
prompt: input.prompt,
target: input.target,
...(input.selection ? { selection: input.selection } : {}),
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
source: sessionOriginFromInboxOrigin(ctx.workspaceId, ctx.origin) ?? {
kind: 'workspace',
Expand Down Expand Up @@ -175,7 +186,7 @@ export const conversationAskFactory: WorkspaceToolFactory = {
build(ctx) {
return tool({
description: [
"Ask a known product Session, an Inbox sender, an Issue's attributable creator, or a fresh worker.",
"Send a follow-up to a Session or attributable author. Use conversation create for a new Session; fresh-target flags here remain compatibility aliases.",
'',
'Use exactly one addressing form: resumeId for an exact Session; inboxId for the',
'sender of one delivery; issueId (optionally scoped by wsId) for Issue creation',
Expand All @@ -191,6 +202,7 @@ export const conversationAskFactory: WorkspaceToolFactory = {
].join('\n'),
inputSchema: z.object({
...conversationAskCommonShape,
...conversationSelectionShape,
resumeId: z.string().min(1).optional()
.describe('Exact product Session to continue. Cannot be combined with another target flag.'),
inboxId: z.string().min(1).optional()
Expand All @@ -210,13 +222,15 @@ export const conversationAskFactory: WorkspaceToolFactory = {
issueId,
harness,
agent,
credential, credentialSource, model, effort,
timeoutMs,
await: shouldAwait = false,
reconstruct = false,
}) => {
if (!ctx.conversation) {
return { ok: false as const, error: 'workspace conversation control is unavailable' }
}
if (credential && credentialSource) return { ok: false, error: 'credential and credential-source are mutually exclusive' }
const targetCount = Number(Boolean(resumeId))
+ Number(Boolean(inboxId))
+ Number(Boolean(issueId))
Expand Down Expand Up @@ -245,6 +259,12 @@ export const conversationAskFactory: WorkspaceToolFactory = {
: { kind: 'workspace' as const, workspaceId: wsId! }
const result = await askWorkspaceConversation(ctx, {
prompt,
...((credential || credentialSource || model || effort) ? { selection: {
...(credential ? { credentialSlug: credential } : {}),
...(credentialSource ? { credentialSource } : {}),
...(model ? { model } : {}),
...(effort ? { reasoningEffort: effort } : {}),
} } : {}),
target,
...(inboxAddress ? { subject: inboxAddress.subject } : {}),
...(agent ? { agent } : {}),
Expand All @@ -260,20 +280,43 @@ export const conversationAskFactory: WorkspaceToolFactory = {
},
}

/** Fresh creation has its own manifest, so no author/Session address can slip in. */
export const conversationCreateFactory: WorkspaceToolFactory = {
name: 'conversation_create',
build(ctx) {
const ask = conversationAskFactory.build(ctx)
return tool({
description: 'Create a new Session and deliver its first prompt. Choose exactly one Workspace or Harness. Retain resumeId and use conversation ask for follow-ups; taskId identifies only this turn.',
inputSchema: z.object({
...conversationAskCommonShape,
...conversationSelectionShape,
wsId: z.string().min(1).optional().describe('Workspace in which to create a new Session.'),
harness: z.enum(['chat', 'autoquant', 'prediction']).optional().describe('Create in this Harness default Workspace.'),
}),
execute: async (input, options) => {
if (Number(Boolean(input.wsId)) + Number(Boolean(input.harness)) !== 1) {
return { ok: false, error: 'conversation create requires exactly one target: --ws-id or --harness; use conversation ask --resume-id to continue a Session' }
}
return ask.execute!(input, options)
},
})
},
}

export const conversationAwaitFactory: WorkspaceToolFactory = {
name: 'conversation_await',
build(ctx) {
return tool({
description: [
'Wait server-side for one conversation task to finish.',
'',
'Use after dispatching several conversation_ask calls so their headless runs execute',
'Use after dispatching several conversation_create or conversation_ask calls so their headless runs execute',
'concurrently. This replaces hand-written sleep loops. With an explicit wait budget,',
'an expired wait returns while the task keeps running; without one, this waits until',
'the task reaches a terminal state.',
].join('\n'),
inputSchema: z.object({
taskId: z.string().min(1).describe('Short taskId returned by conversation_ask.'),
taskId: z.string().min(1).describe('Short taskId returned by conversation create or ask.'),
timeoutMs: z.coerce.number().int().positive().max(MAX_TIMEOUT_MS).optional()
.describe('Optional server-side wait budget in milliseconds. Omit to wait until the task finishes.'),
}),
Expand Down Expand Up @@ -366,14 +409,14 @@ export const conversationReadFactory: WorkspaceToolFactory = {
build(ctx) {
return tool({
description: [
'Read one headless follow-up started by conversation_ask.',
'Read one turn started by conversation create or ask.',
'',
'Summary returns the latest assistant reply and one compact failure when present.',
'Tool activity and normalized message blocks are available only in detailed mode.',
'Running tasks may have partial output.',
].join('\n'),
inputSchema: z.object({
taskId: z.string().min(1).describe('taskId returned by conversation_ask.'),
taskId: z.string().min(1).describe('taskId returned by conversation create or ask.'),
mode: z.enum(['summary', 'detailed']).optional().default('summary')
.describe('`summary` returns status and assistant text; `detailed` also returns normalized tool, error, and message blocks.'),
}),
Expand All @@ -397,6 +440,7 @@ export const conversationReadFactory: WorkspaceToolFactory = {
}

export const conversationToolFactories: WorkspaceToolFactory[] = [
conversationCreateFactory,
conversationAskFactory,
conversationAwaitFactory,
conversationCollectFactory,
Expand Down
4 changes: 2 additions & 2 deletions src/workspaces/context-injector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ describe('injectWorkspaceContext — skills', () => {
});
for (const root of ['.claude/skills', '.agents/skills']) {
const skill = await read(`${root}/delegate-autoquant/SKILL.md`);
expect(skill).toContain('alice conversation ask --harness autoquant');
expect(skill).toContain('alice conversation create --harness autoquant');
expect(skill).toContain('The universal result is the Agent\'s ordinary `assistantText` handoff');
expect(skill).toContain('does not automatically publish either artifact to the');
expect(skill).toContain('Primary deliverable directory: <absolute path>');
Expand Down Expand Up @@ -145,7 +145,7 @@ describe('injectWorkspaceContext — skills', () => {
expect(skill).toContain('alice peer path --id <workspaceId>');
expect(skill).toContain("Coding Agent's native Read/Search/Glob/Git capabilities");
expect(skill).toContain('alice conversation ask --inbox-id <entryId>');
expect(skill).toContain('alice conversation ask --harness autoquant');
expect(skill).toContain('alice conversation create --harness autoquant');
expect(skill).not.toContain('peer file-read');
});

Expand Down
3 changes: 2 additions & 1 deletion src/workspaces/conversation-control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ describe('Workspace conversation control', () => {
const result = await createWorkspaceConversationControl(svc).ask({
target: { kind: 'issue', workspaceId: 'ws-peer', issueId: 'audit' },
prompt: 'Why did you create this?',
selection: { model: 'custom-model', reasoningEffort: 'high' },
timeoutMs: 300_000,
})

Expand All @@ -334,7 +335,7 @@ describe('Workspace conversation control', () => {
undefined,
'resume-peer',
undefined,
undefined,
{ model: 'custom-model', reasoningEffort: 'high' },
expect.objectContaining({
originalPrompt: 'Why did you create this?',
deliveredPrompt: 'Why did you create this?',
Expand Down
4 changes: 2 additions & 2 deletions src/workspaces/conversation-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ export function createWorkspaceConversationControl(
undefined,
continuingOrigin?.resumeId,
inquiry,
undefined,
input.selection,
conversation,
createdBy,
)
Expand All @@ -371,7 +371,7 @@ export function createWorkspaceConversationControl(
undefined,
continuingOrigin?.resumeId,
undefined,
undefined,
input.selection,
conversation,
createdBy,
)
Expand Down
Loading