Merge branch 'stack/agent-profiles-4-settings' into stack/agent-profiles-5-web-ui
# Conflicts: # apps/cli/package.json # pnpm-lock.yaml
This commit is contained in:
@@ -15,20 +15,12 @@
|
||||
text: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
# `tool-bash` provides the `bashEnv` service, so it needs a realm even alone.
|
||||
- id: shell
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
bashEnv: true
|
||||
config:
|
||||
# The registry and its consumer share the realm: a consumer left outside
|
||||
# would resolve the host's `bashEnv`, which this plane no longer provides.
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership. `tool-bash` consumes that host
|
||||
# registry from here.
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
|
||||
@@ -31,22 +31,15 @@
|
||||
|
||||
# ── shell ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# `tool-bash` reads as a tool but provides the `bashEnv` service, so it needs a
|
||||
# realm like any other provider. The executor behind it (`bash-sandbox`) stays
|
||||
# in the host composition, where the sandbox policy owns it.
|
||||
- id: shell
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
bashEnv: true
|
||||
config:
|
||||
# The registry and its consumer share the realm: a consumer left outside
|
||||
# would resolve the host's `bashEnv`, which this plane no longer provides.
|
||||
- id: bash-env
|
||||
name: '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
|
||||
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
|
||||
# the criterion for host-plane ownership — injection resolves before any session
|
||||
# exists, so there is no agent to key by. Behind a preset realm those variables
|
||||
# never reached the model's shell at all. `tool-bash` consumes the host registry
|
||||
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
|
||||
# sandbox policy owns it.
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
# ── filesystem ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -172,29 +165,21 @@
|
||||
|
||||
# ── delegation and workflows ────────────────────────────────────────────────
|
||||
|
||||
# Every backend and every tool that reaches `subagents` or `workflows` shares
|
||||
# one realm: a consumer left outside it would resolve the host's registry
|
||||
# instead, which this preset does not populate.
|
||||
# The `subagents` registry and its spawn/fork backends live in the HOST
|
||||
# composition: the registry is a process singleton whose cross-session queries
|
||||
# the api-proxy serves to the browser, and a provider name may only be
|
||||
# registered once. This preset contributes the delegation TOOLS, which resolve
|
||||
# that host registry.
|
||||
#
|
||||
# `workflows` is different — nothing outside an agent reads it — so every row
|
||||
# that reaches it shares one entry-local realm here, and a consumer left
|
||||
# outside would resolve a host registry this preset does not populate.
|
||||
- id: delegation
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
subagents: true
|
||||
workflows: true
|
||||
config:
|
||||
- id: subagent
|
||||
name: '@deepseek-ai/dsh-subagent'
|
||||
|
||||
- id: subagent-spawn
|
||||
name: '@deepseek-ai/dsh-subagent-spawn'
|
||||
config:
|
||||
providerName: spawn
|
||||
|
||||
- id: subagent-fork
|
||||
name: '@deepseek-ai/dsh-subagent-fork'
|
||||
config:
|
||||
providerName: fork
|
||||
|
||||
- id: tool-subagent-control
|
||||
name: '@deepseek-ai/dsh-tool-subagent-control'
|
||||
|
||||
@@ -215,8 +200,11 @@
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
|
||||
- id: tool-subagent-report
|
||||
name: '@deepseek-ai/dsh-tool-subagent-report'
|
||||
# `tool-subagent-report` is host-plane for the same reason as the registry,
|
||||
# not because a preset may not want it: it registers a CONTINUABLE SETUP on
|
||||
# that singleton rather than a tool this agent calls, and the setup list is
|
||||
# not scope-aware — one copy per mounted preset means every child gets
|
||||
# `report` registered once per live session, which throws on the second.
|
||||
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
@@ -234,6 +222,9 @@
|
||||
|
||||
# ── remaining model-facing rows ─────────────────────────────────────────────
|
||||
|
||||
- id: tool-ask-user
|
||||
name: '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
|
||||
|
||||
@@ -20,17 +20,44 @@
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-base": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-headless": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-persona": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tmux-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-app": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"commander": "^15.0.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"js-yaml": "^4.2.0",
|
||||
|
||||
@@ -66,11 +66,13 @@ beforeAll(async () => {
|
||||
}, 120_000)
|
||||
|
||||
describe('the shipped Web composition', () => {
|
||||
it('leaves only the host UI tool in the global layer', () => {
|
||||
// `ask_user_question` is the host's own interaction surface, not an agent
|
||||
// capability, so it stays global. Every other tool now belongs to a
|
||||
// preset; a regression here means an agent-plane row came back to base.
|
||||
expect(toolNames(ctx)).toEqual(['ask_user_question'])
|
||||
it('leaves the global tool layer empty', () => {
|
||||
// Every model-facing tool belongs to a preset, `ask_user_question`
|
||||
// included: a tool in the global layer reaches EVERY agent regardless of
|
||||
// which preset composed it, so a two-tool benchmark surface would really
|
||||
// present three. A regression here means an agent-plane row came back to
|
||||
// the host composition.
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
})
|
||||
|
||||
it('supplies both shipped presets, and only those, from the system root', async () => {
|
||||
@@ -110,7 +112,8 @@ describe('the shipped Web composition', () => {
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor'])
|
||||
// Exactly what the preset lists — nothing arrives from the host.
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
@@ -126,14 +129,14 @@ describe('the shipped Web composition', () => {
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
expect(toolNames(ctx, minimal.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor'])
|
||||
expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor'])
|
||||
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
||||
|
||||
await minimal.dispose()
|
||||
|
||||
// Tearing the minimal session down leaves the full one whole.
|
||||
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
||||
expect(toolNames(ctx)).toEqual(['ask_user_question'])
|
||||
expect(toolNames(ctx)).toEqual([])
|
||||
} finally {
|
||||
await full.dispose()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
async function writeSkill(root: string, name: string): Promise<void> {
|
||||
@@ -37,10 +39,26 @@ it('isolates replay skill discovery from every ambient host root', async () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
try {
|
||||
scaffold = await launchWebScaffold()
|
||||
const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name)
|
||||
expect(names).not.toContain('ambient-dsh')
|
||||
expect(names).not.toContain('ambient-agents')
|
||||
expect(names).not.toContain('ambient-bundled')
|
||||
const ctx = scaffold.ctx
|
||||
// The skill registry belongs to one agent's preset, behind an `isolate`
|
||||
// realm the host cannot resolve by name — so the roots under test are only
|
||||
// reachable through a composed agent, which is also the only shape that
|
||||
// ever asks. `serviceFor` is the same addressing the gateway's `skill.list`
|
||||
// uses for a browser request about a session.
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('hermetic-skills'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const skills = ctx.agentPresets.serviceFor(handle.agent, 'skills')
|
||||
if (skills === undefined) throw new Error('composed agent mounts no skill registry')
|
||||
const names = (await skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name)
|
||||
expect(names).not.toContain('ambient-dsh')
|
||||
expect(names).not.toContain('ambient-agents')
|
||||
expect(names).not.toContain('ambient-bundled')
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await scaffold?.close()
|
||||
|
||||
@@ -240,6 +240,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// paths at load, and an in-process boot must NEVER touch the developer's
|
||||
// real ~/.dsh document or credential file.
|
||||
const harnessHome = join(workspaceCwd, '.dsh-home')
|
||||
// Skill discovery is model-visible input, and its roots now resolve inside a
|
||||
// PRESET — a subtree this lane's include patches cannot reach, because the
|
||||
// roster mounts it directly per session rather than as a row of the booted
|
||||
// tree. The row's documented fallback is the environment, so pin that: the
|
||||
// whole scaffold lifetime, not just the boot, since presets mount when a
|
||||
// session is created. Without this a developer's real ~/.dsh/skills silently
|
||||
// enters replay requests and goldens while CI sees none.
|
||||
const skillRootEnvironment = {
|
||||
DSH_HOME: join(workspaceCwd, '.dsh-home'),
|
||||
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
|
||||
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
|
||||
}
|
||||
const originalSkillRootEnvironment = Object.fromEntries(
|
||||
Object.keys(skillRootEnvironment).map(key => [key, process.env[key]]),
|
||||
)
|
||||
let skillRootEnvironmentRestored = false
|
||||
const restoreSkillRootEnvironment = (): void => {
|
||||
if (skillRootEnvironmentRestored) return
|
||||
skillRootEnvironmentRestored = true
|
||||
for (const [key, value] of Object.entries(originalSkillRootEnvironment)) {
|
||||
if (value === undefined) Reflect.deleteProperty(process.env, key)
|
||||
else process.env[key] = value
|
||||
}
|
||||
}
|
||||
Object.assign(process.env, skillRootEnvironment)
|
||||
let persistenceRoot: string
|
||||
try {
|
||||
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
|
||||
@@ -415,6 +440,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
|
||||
restoreCredentialEnvironment()
|
||||
restoreSkillRootEnvironment()
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
|
||||
}
|
||||
@@ -462,6 +488,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
} finally {
|
||||
restoreCredentialEnvironment()
|
||||
restoreSkillRootEnvironment()
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import { join } from 'node:path'
|
||||
@@ -170,10 +171,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
if (MODE !== 'record') {
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
|
||||
const meter = scaffold.ctx.get('tokenMeter')
|
||||
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
|
||||
const realized = realizeSeedFixture(scaffold, raw, SEED_ID)
|
||||
await seedSession(scaffold, withCompaction(realized, meter), SEED_ID)
|
||||
// The meter belongs to an agent's preset, not to the process — token
|
||||
// accounting is per session. It is used here as a pure pricing function
|
||||
// over fixture content, so a throwaway composition is enough to reach one.
|
||||
const priced = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('seeded-history-pricing'),
|
||||
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
let realizedWithCompaction: string
|
||||
try {
|
||||
const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter')
|
||||
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
|
||||
realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
|
||||
} finally {
|
||||
await priced.dispose()
|
||||
}
|
||||
await seedSession(scaffold, realizedWithCompaction, SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
@@ -220,11 +233,17 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
const projections = body.result.value?.projections
|
||||
expect(projections).toBeDefined()
|
||||
expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
|
||||
// The seed carries a session/title event: the title unit must serve it.
|
||||
// The seed carries a session/title event: the title unit is host-plane, so
|
||||
// it folds the detached log and serves the value with nothing composed.
|
||||
expect(typeof projections?.values.title).toBe('string')
|
||||
// tool-todo is composed but the seed has no todo/write: whole-value null,
|
||||
// key PRESENT (absence would mean the unit never registered).
|
||||
expect(projections?.values).toHaveProperty('todos', null)
|
||||
// `todos` is NOT here, and that is the contract rather than a gap. Its unit
|
||||
// is registered by `tool-todo` inside an agent's preset, so a detached
|
||||
// session yields it from exactly one place: a durable checkpoint written
|
||||
// while the session was live. This seed was written straight to persistence
|
||||
// and never ran, so it recorded none — and the answer no longer depends on
|
||||
// whether some UNRELATED session happens to be composed right now, which is
|
||||
// the whole reason the checkpoint row carries its own view.
|
||||
expect(projections?.values).not.toHaveProperty('todos')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
/**
|
||||
@@ -63,11 +65,26 @@ afterEach(async () => {
|
||||
|
||||
it('assembles the shipped Web catalog with the confined access default', async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
|
||||
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
|
||||
// The packaged ripgrep binary ships with the dependency, so the pair is a
|
||||
// fixed roster member on every host.
|
||||
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
|
||||
const ctx = scaffold.ctx
|
||||
// The catalog belongs to an AGENT, not to the process: every model-facing row
|
||||
// now lives in a preset mounted under one session's scope, so the global
|
||||
// layer holds nothing and a caller must name the agent to see anything. This
|
||||
// composes from the deployment default — what a session that names no preset
|
||||
// gets — which is the shape this test has always been about.
|
||||
expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([])
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('shipped-composition'),
|
||||
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
try {
|
||||
const names = ctx.tools.schemas(handle.agent).map(schema => schema.name).sort()
|
||||
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
|
||||
// The packaged ripgrep binary ships with the dependency, so the pair is a
|
||||
// fixed roster member on every host.
|
||||
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
// `workspace-write` is not "the workspace and nothing else": the shared roots
|
||||
// helper always admits the temp directories too. Pinning it against an
|
||||
// explicit mode keeps the claim independent of this surface's default, and
|
||||
|
||||
Reference in New Issue
Block a user