Skip to content
Closed
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
27 changes: 27 additions & 0 deletions default/skills/alice-workspace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,33 @@ alice-workspace track search --query "uranium"
alice-workspace track add --name uranium-ccj --description "Cameco — uranium miner"
```

**Ask the agent who produced something** — continue the exact OpenAlice
conversation when its `resumeId` is available:

```bash
alice-workspace conversation ask --resumeId <resumeId> --prompt "Why did you make this call?"
# -> { taskId, resumeId, workspace, agent, status: "running" }
alice-workspace conversation read --taskId <taskId>
```

`ask` is asynchronous. Poll `read` until its status is no longer `running`; the
default response contains the latest assistant reply plus compact tool/error
activity. Add `--mode detailed` only when you need normalized tool inputs,
outputs, and all message blocks.

If the originating conversation is unavailable but you know which desk owns the
work, start a fresh worker there instead:

```bash
alice-workspace conversation ask --workspaceId <workspaceId> --prompt "Reconstruct why this issue exists."
```

This goes through the embedded Workspace service and normal headless registry,
not the public HTTP API. A fresh target uses the user's configured default
runtime when that runtime is enabled there, otherwise the workspace's first
enabled runtime; `--agent <id>` overrides that choice. Never pass both
`--resumeId` and `--workspaceId`.

**The issue board** — the cross-workspace work list, shared by you and the user.
It's *what's on the plate* when you've lost the thread — scan it when you start.
**Reads are global, writes are local:**
Expand Down
14 changes: 14 additions & 0 deletions docs/workspace-issues-and-scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,20 @@ remain valid and are never renamed. `taskId` remains one execution, while
`parentTaskId` records direct turn lineage. Runs and their logs are retained
rather than silently pruned.

Internal agents use the same identity through the embedded Workspace CLI:

```bash
alice-workspace conversation ask --resumeId <resumeId> --prompt "Why?"
alice-workspace conversation read --taskId <taskId>
```

The ask path calls `WorkspaceService.dispatchHeadlessTask` directly; it does not
loop back through the authenticated public `/api` surface or expose the native
runtime session id. If no origin conversation exists, `--workspaceId` starts a
fresh headless conversation at the owning workspace. Both paths create ordinary
durable run records, obey global capacity and per-resume concurrency guards, and
return a `taskId` whose normalized reply/tool blocks are polled with `read`.

Scheduling never bypasses trading approval. A headless agent may research or
stage a trade, but execution remains behind UTA/Trading-as-Git permission and
human approval boundaries.
Expand Down
37 changes: 37 additions & 0 deletions src/core/workspace-tool-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,41 @@ import type { IEntityStore } from './entity-store.js'
// core/ free of any runtime dependency on the workspaces/ module (no
// core→workspaces coupling), while letting the board reader below be typed.
import type { IssuesSnapshot, IssueDetail, WikilinkIssueRef } from '../workspaces/issues/board.js'
import type { HeadlessStructuredOutput } from '../workspaces/headless-output.js'
import type { HeadlessTaskStatus } from '../workspaces/headless-task-registry.js'

export interface WorkspaceConversationTask {
readonly taskId: string
readonly resumeId: string
readonly parentTaskId?: string
readonly workspaceId: string
readonly issueId?: string
readonly agent: string
readonly status: HeadlessTaskStatus
readonly startedAt: number
readonly finishedAt?: number
readonly durationMs?: number
readonly error?: string
readonly structured: HeadlessStructuredOutput | null
}

export interface WorkspaceConversationControl {
ask(input: {
readonly prompt: string
readonly timeoutMs: number
readonly resumeId?: string
readonly workspaceId?: string
readonly agent?: string
}): Promise<{
readonly taskId: string
readonly resumeId: string
readonly workspaceId: string
readonly workspace: string
readonly agent: string
readonly continued: boolean
}>
read(taskId: string): Promise<WorkspaceConversationTask | null>
}

// ==================== Context handed to factories ====================

Expand All @@ -56,6 +91,8 @@ export interface WorkspaceToolContext {
* it needs the live WorkspaceService (created after this center); the two
* build sites (cli.ts, mcp.ts) inject a lazy closure, tests may omit it. */
resolveWorkspace?: (id: string) => { id: string; dir: string; tag: string } | null
/** Embedded headless conversation control; never routes through public HTTP. */
conversation?: WorkspaceConversationControl
/** Agent-INVISIBLE run provenance, resolved server-side from the
* `x-openalice-run` header by the MCP / CLI route (never supplied by the
* agent). Factories pass it through to call sites (e.g. inbox_push →
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { createEntityStore } from './core/entity-store.js'
import { entityUpsertFactory } from './tool/entity-upsert.js'
import { entitySearchFactory } from './tool/entity-search.js'
import { issueToolFactories } from './tool/issue-tools.js'
import { conversationToolFactories } from './tool/conversation.js'
import { createToolCallLog } from './core/tool-call-log.js'
import { NewsCollectorStore, NewsCollector } from './domain/news/index.js'
import { createNewsArchiveTools } from './tool/news.js'
Expand Down Expand Up @@ -112,6 +113,7 @@ async function main() {
workspaceToolCenter.register(entityUpsertFactory)
workspaceToolCenter.register(entitySearchFactory)
for (const f of issueToolFactories) workspaceToolCenter.register(f)
for (const f of conversationToolFactories) workspaceToolCenter.register(f)

// ==================== UTA SDK (HTTP boundary) ====================
//
Expand Down
2 changes: 2 additions & 0 deletions src/server/cli-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { workspacePathFactory } from '../tool/workspace-path.js'
import { entityUpsertFactory } from '../tool/entity-upsert.js'
import { entitySearchFactory } from '../tool/entity-search.js'
import { issueToolFactories } from '../tool/issue-tools.js'
import { conversationToolFactories } from '../tool/conversation.js'
import { createTradingTools } from '../tool/trading.js'

/**
Expand Down Expand Up @@ -87,6 +88,7 @@ describe('CLI_EXPORTS — workspace export (scoped collaboration tools)', () =>
wtc.register(entityUpsertFactory)
wtc.register(entitySearchFactory)
for (const f of issueToolFactories) wtc.register(f)
for (const f of conversationToolFactories) wtc.register(f)
const built = wtc.build({
workspaceId: 'ws-test',
workspaceLabel: 'test',
Expand Down
9 changes: 8 additions & 1 deletion src/server/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export const CLI_EXPORTS: Record<string, CliExport> = {
workspace: {
binary: 'alice-workspace',
scope: 'scoped',
description: 'Agent collaboration — push/read the user inbox, locate a peer workspace (peer path), track entities',
description: 'Agent collaboration — Inbox, peer workspaces, issues, tracked entities, and headless conversation follow-ups',
commands: {
// inbox push: surface doc(s) + comment to the user's Inbox tab. Attach
// files with repeatable `--doc <path>` (the shim folds them into the
Expand Down Expand Up @@ -181,6 +181,13 @@ export const CLI_EXPORTS: Record<string, CliExport> = {
list: 'issue_list',
show: 'issue_show',
},
// conversation: ask the exact originating worker by product resumeId,
// or start a fresh worker at a peer workspace when provenance is absent.
// Results stay in the normal headless registry and are read by taskId.
conversation: {
ask: 'conversation_ask',
read: 'conversation_read',
},
},
},
uta: {
Expand Down
64 changes: 63 additions & 1 deletion src/server/cli.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { Hono } from 'hono'
import { ToolCenter } from '../core/tool-center.js'
import { WorkspaceToolCenter } from '../core/workspace-tool-center.js'
Expand All @@ -8,6 +8,7 @@ import { workspacePathFactory } from '../tool/workspace-path.js'
import { createMemoryInboxStore } from '../core/inbox-store.js'
import { extractMcpShape } from '../core/mcp-export.js'
import { inboxPushFactory } from '../tool/inbox-push.js'
import { conversationAskFactory } from '../tool/conversation.js'
import { registerCliRoutes, type CliGatewayDeps } from './cli.js'

/**
Expand Down Expand Up @@ -184,6 +185,67 @@ describe('CLI gateway — inbox read (scoped, string-arg coercion)', () => {
})
})

describe('CLI gateway — embedded conversation dispatch', () => {
it('coerces timeoutMs and dispatches by resumeId without public HTTP', async () => {
const adapter = {
id: 'pi',
kind: 'agent',
capabilities: { headless: true },
composeHeadlessCommand: () => ['pi'],
}
const target = {
id: 'ws-peer', tag: 'peer', dir: '/tmp/peer', createdAt: '2026-07-11T00:00:00.000Z', agents: ['pi'],
}
const dispatchHeadlessTask = vi.fn(async () => ({ taskId: 'task-1', resumeId: 'resume-1' }))
const fakeSvc = {
registry: {
get: (id: string) => id === 'ws1'
? { id: 'ws1', tag: 'caller' }
: id === target.id ? target : undefined,
},
resumeRegistry: {
get: (id: string) => id === 'resume-1'
? { resumeId: id, wsId: target.id, agent: 'pi' }
: null,
},
adapters: { get: (id: string) => id === 'pi' ? adapter : undefined },
config: { launcherRepoRoot: '/repo' },
dispatchHeadlessTask,
}
const wtc = new WorkspaceToolCenter()
wtc.register(conversationAskFactory)
const app = new Hono()
registerCliRoutes(app, {
toolCenter: new ToolCenter(),
workspaceToolCenter: wtc,
inboxStore: {} as never,
entityStore: {} as never,
getWorkspaceService: () => fakeSvc as never,
})

const res = await app.request('/cli/ws1/workspace/invoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool: 'conversation_ask',
args: { resumeId: 'resume-1', prompt: 'why?', timeoutMs: '1234' },
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { content: Array<{ text?: string }> }
const payload = JSON.parse(body.content.map((block) => block.text ?? '').join(''))
expect(payload).toMatchObject({ ok: true, taskId: 'task-1', continued: true })
expect(dispatchHeadlessTask).toHaveBeenCalledWith(
target,
adapter,
'why?',
1234,
undefined,
'resume-1',
)
})
})

describe('CLI gateway — agent-invisible origin (x-openalice-run → registry)', () => {
// The `alice` shim forwards the spawn-injected AQ_RUN_ID as an `x-openalice-run`
// header on /invoke. The gateway resolves it through the headless registry
Expand Down
2 changes: 2 additions & 0 deletions src/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { type WorkspaceToolCenter, makeWorkspaceResolver } from '../core/workspa
import type { IInboxStore, InboxOrigin } from '../core/inbox-store.js'
import type { IEntityStore } from '../core/entity-store.js'
import type { WorkspaceService } from '../workspaces/service.js'
import { createWorkspaceConversationControl } from '../workspaces/conversation-control.js'
import { extractMcpShape, wrapToolExecute } from '../core/mcp-export.js'
import { type CliExport, getExport, mappedToolNames } from './cli-commands.js'
import { resolveInboxOrigin } from './inbox-origin.js'
Expand Down Expand Up @@ -87,6 +88,7 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void {
resolveWorkspace: makeWorkspaceResolver(getWorkspaceService),
...(svc
? {
conversation: createWorkspaceConversationControl(svc),
board: {
snapshot: () => svc.issuesSnapshot(),
detail: (w: string, i: string) => svc.issueDetail(w, i),
Expand Down
2 changes: 2 additions & 0 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { type WorkspaceToolCenter, makeWorkspaceResolver } from '../core/workspa
import type { IInboxStore } from '../core/inbox-store.js'
import type { IEntityStore } from '../core/entity-store.js'
import type { WorkspaceService } from '../workspaces/service.js'
import { createWorkspaceConversationControl } from '../workspaces/conversation-control.js'
import type { InboxOrigin } from '../core/inbox-store.js'
import { extractMcpShape, wrapToolExecute } from '../core/mcp-export.js'
import { registerCliRoutes } from './cli.js'
Expand Down Expand Up @@ -103,6 +104,7 @@ export class McpPlugin implements Plugin {
resolveWorkspace: makeWorkspaceResolver(getWorkspaceService),
...(svc
? {
conversation: createWorkspaceConversationControl(svc),
board: {
snapshot: () => svc.issuesSnapshot(),
detail: (w: string, i: string) => svc.issueDetail(w, i),
Expand Down
97 changes: 97 additions & 0 deletions src/tool/conversation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { Tool } from 'ai'
import { describe, expect, it, vi } from 'vitest'

import type { WorkspaceToolContext } from '../core/workspace-tool-center.js'
import { conversationAskFactory, conversationReadFactory } from './conversation.js'

async function run(tool: Tool, args: Record<string, unknown>) {
return tool.execute!(args, { toolCallId: 'test', messages: [] })
}

function context(over: Partial<WorkspaceToolContext> = {}): WorkspaceToolContext {
return {
workspaceId: 'ws-caller',
workspaceLabel: 'caller',
inboxStore: {} as never,
entityStore: {} as never,
...over,
}
}

describe('conversation_ask', () => {
it('requires exactly one target', async () => {
const tool = conversationAskFactory.build(context({
conversation: { ask: vi.fn(), read: vi.fn() },
}))

await expect(run(tool, { prompt: 'why?' })).resolves.toMatchObject({ ok: false })
await expect(run(tool, {
prompt: 'why?', resumeId: 'resume-1', workspaceId: 'ws-1',
})).resolves.toMatchObject({ ok: false })
})

it('dispatches a resumed follow-up and returns its task handle', async () => {
const ask = vi.fn(async () => ({
taskId: 'task-1',
resumeId: 'resume-1',
workspaceId: 'ws-peer',
workspace: 'peer',
agent: 'pi',
continued: true,
}))
const tool = conversationAskFactory.build(context({
conversation: { ask, read: vi.fn() },
}))

await expect(run(tool, {
prompt: 'why?', resumeId: 'resume-1',
})).resolves.toMatchObject({ ok: true, status: 'running', taskId: 'task-1' })
expect(ask).toHaveBeenCalledWith({
prompt: 'why?', resumeId: 'resume-1', timeoutMs: 300_000,
})
})
})

describe('conversation_read', () => {
const task = {
taskId: 'task-1',
resumeId: 'resume-1',
workspaceId: 'ws-peer',
agent: 'pi',
status: 'done' as const,
startedAt: 1,
durationMs: 2,
structured: {
schemaVersion: 1 as const,
assistantText: 'The report followed the issue rule.',
blocks: [
{ type: 'tool' as const, id: 'tool-1', name: 'Read', status: 'completed' as const, input: 'a.md', output: 'ok' },
{ type: 'text' as const, text: 'The report followed the issue rule.' },
],
metrics: { textBlocks: 1, toolCalls: 1, toolFailures: 0 },
truncated: false,
},
}

it('keeps default output decision-oriented', async () => {
const tool = conversationReadFactory.build(context({
conversation: { ask: vi.fn(), read: vi.fn(async () => task) },
}))
const result = await run(tool, { taskId: task.taskId })

expect(result).toMatchObject({
ok: true,
assistantText: 'The report followed the issue rule.',
tools: [{ name: 'Read', status: 'completed' }],
})
expect(result).not.toHaveProperty('blocks')
})

it('returns normalized blocks only in detailed mode', async () => {
const tool = conversationReadFactory.build(context({
conversation: { ask: vi.fn(), read: vi.fn(async () => task) },
}))
await expect(run(tool, { taskId: task.taskId, mode: 'detailed' }))
.resolves.toMatchObject({ blocks: task.structured.blocks })
})
})
Loading
Loading