Skip to content
Draft
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
8 changes: 5 additions & 3 deletions packages/cli/src/cli-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,11 @@ Commands:
persona-maker with the task as input, or exits non-zero
(non-TTY) with a hint.
Exit codes: 0 match, 2 no match, 3 picker unavailable.
deploy <persona-path> [flags]
Deploy a persona as a managed agent. <persona-path> may
be prebuilt persona.json or authored persona.ts/js.
deploy <persona-id|persona-path> [flags]
Deploy a persona as a managed agent. A bare id resolves
through the registry cascade (including agents kept in
.agentworkforce/workforce/agents/<name>/); a path may be
prebuilt persona.json or authored persona.ts/js.
Modes:
--mode dev run the persona locally (default if
no Daytona/workspace creds resolve)
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import path from 'node:path';
import {
configureDeployCommandForTest,
formatDeployFailure,
looksLikeDeployPath,
parseDeployArgs,
resolveDeployPersonaSelector,
runLogin,
runLogout,
withDefaultDeployMode
Expand Down Expand Up @@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
restoreDeps();
}
});

// --- persona selector -------------------------------------------------------
// `deploy` takes a persona id as well as a path, so an agent living in
// `.agentworkforce/workforce/agents/<name>/` deploys by name.

test('looksLikeDeployPath: path syntax and persona extensions are paths', () => {
for (const selector of [
'./persona.json',
'../agents/x/persona.ts',
'/tmp/review/persona.ts',
'~/personas/thing.json',
'agents/proposal-agent/persona.json',
'persona.json',
'persona.ts'
]) {
assert.equal(looksLikeDeployPath(selector), true, selector);
}
});

test('looksLikeDeployPath: a bare id is not a path', () => {
for (const selector of ['proposal-agent', 'customer-dev', 'persona-maker']) {
assert.equal(looksLikeDeployPath(selector), false, selector);
}
});

test('resolveDeployPersonaSelector: a path resolves without touching the registry', () => {
assert.equal(
resolveDeployPersonaSelector('./persona.json'),
path.resolve('./persona.json')
);
});

test('resolveDeployPersonaSelector: an id resolves to the declaring file', async () => {
const { mkdtempSync, mkdirSync, rmSync, writeFileSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');

const root = mkdtempSync(join(tmpdir(), 'aw-deploy-selector-'));
const agentDir = join(root, '.agentworkforce', 'workforce', 'agents', 'proposal-agent');
mkdirSync(agentDir, { recursive: true });
writeFileSync(
join(agentDir, 'persona.json'),
JSON.stringify({ id: 'proposal-agent', extends: 'persona-maker' })
);
const cwd = process.cwd();
try {
process.chdir(root);
// realpath: macOS tmpdirs are symlinks, and the registry resolves through them.
const { realpathSync } = await import('node:fs');
assert.equal(
realpathSync(resolveDeployPersonaSelector('proposal-agent')),
realpathSync(join(agentDir, 'persona.json'))
);
} finally {
process.chdir(cwd);
rmSync(root, { recursive: true, force: true });
}
});

test('resolveDeployPersonaSelector: a built-in id explains it has no file', () => {
const trap = trapExit();
try {
assert.throws(
() => resolveDeployPersonaSelector('persona-maker'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. resolveDeployPersonaSelector('persona-maker') resolves through the registry cascade, where a local persona named persona-maker under the runner's cwd (process.cwd()) or in the configurable persona dirs (default ~/.agentworkforce/workforce/personas) wins over the built-in catalog and returns a real file path, so the /no file to deploy/ assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.test.ts, line 548:

<comment>The built-in-id test is not isolated from ambient developer configuration, so it can fail (or false-pass) depending on the machine it runs on. `resolveDeployPersonaSelector('persona-maker')` resolves through the registry cascade, where a local persona named `persona-maker` under the runner's cwd (`process.cwd()`) or in the configurable persona dirs (default `~/.agentworkforce/workforce/personas`) wins over the built-in catalog and returns a real file path, so the `/no file to deploy/` assertion fails even though the behavior under test is correct. The other new selector test isolates this by chdir'ing into a fresh mkdtemp root; this one leaves cwd and the ambient config untouched. Run the assertion from an isolated temporary cwd (and remove it in finally) so only the built-in resolution drives the outcome.</comment>

<file context>
@@ -480,3 +482,74 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the
+  const trap = trapExit();
+  try {
+    assert.throws(
+      () => resolveDeployPersonaSelector('persona-maker'),
+      /__exit_trap__/
+    );
</file context>

/__exit_trap__/
);
} finally {
trap.restore();
}
assert.match(trap.stderr, /no file to deploy/);
});
60 changes: 56 additions & 4 deletions packages/cli/src/deploy-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import {
setWorkspaceKey,
type StoredAuth
} from '@agent-relay/cloud';
import {
formatPersonaSourceLabel,
PersonaResolutionError,
resolvePersonaReference
} from '@agentworkforce/persona-registry';
import {
canonicalizeCloudUrl,
clearActiveWorkspace,
Expand Down Expand Up @@ -67,7 +72,7 @@ export function configureDeployCommandForTest(overrides: Partial<DeployCommandDe
}

/**
* Argv parser + dispatcher for `agentworkforce deploy <persona-path> [flags]`.
* Argv parser + dispatcher for `agentworkforce deploy <persona-id|persona-path>`.
* Keeps cli.ts itself slim — the file is already a large dispatcher and
* each command lands in its own module when it grows past trivial.
*/
Expand Down Expand Up @@ -253,7 +258,11 @@ export async function runLogout(args: readonly string[]): Promise<void> {
}
}

const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-path> [flags]
const DEPLOY_USAGE = `usage: agentworkforce deploy <persona-id|persona-path> [flags]

A bare id resolves through the registry cascade — including agents kept in
.agentworkforce/workforce/agents/<name>/. A path may be a prebuilt persona.json
or an authored persona.ts/js.

Flags:
--mode dev|sandbox|cloud Pick a run mode (prompts in an interactive terminal)
Expand Down Expand Up @@ -303,6 +312,49 @@ Flags:

const ON_EXISTS_CHOICES = ['update', 'destroy', 'cancel'] as const;

/**
* A selector is a path when it carries path syntax or a persona-source
* extension. Anything else is a persona id looked up through the registry
* cascade, so an agent that lives in `.agentworkforce/workforce/agents/<name>/`
* deploys by name from anywhere in the repo.
*
* Syntax decides, not the filesystem: a bare `proposal-agent` that happens to
* match a directory in cwd must still mean the persona, or the same command
* would deploy different things depending on where it ran.
*/
export function looksLikeDeployPath(selector: string): boolean {
return (
selector.startsWith('.') ||
selector.startsWith('/') ||
selector.startsWith('~') ||
selector.includes(path.sep) ||
selector.includes('/') ||
isPersonaSourcePath(selector) ||
selector.toLowerCase().endsWith('.json')
);
}

export function resolveDeployPersonaSelector(selector: string): string {
if (looksLikeDeployPath(selector)) return path.resolve(selector);

let resolved;
try {
resolved = resolvePersonaReference(selector);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid interactive validation for deploy selectors

When a valid handler-based deploy persona omits harness, model, or systemPrompt, this call fails before returning its file path because resolvePersonaReference unconditionally builds an interactive PersonaSelection, whose validator rejects those omissions. Deploy's persona parser explicitly permits these fields to be absent when onEvent is present, so the same persona deploys by path but cannot deploy by the newly supported bare ID; use a registry lookup that does not require the interactive projection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Resolve ID selectors without constructing an interactive PersonaSelection; otherwise handler personas that omit harness, model, or systemPrompt fail before deploy can obtain their path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 342:

<comment>Resolve ID selectors without constructing an interactive `PersonaSelection`; otherwise handler personas that omit `harness`, `model`, or `systemPrompt` fail before deploy can obtain their path.</comment>

<file context>
@@ -303,6 +312,49 @@ Flags:
+
+  let resolved;
+  try {
+    resolved = resolvePersonaReference(selector);
+  } catch (err) {
+    if (err instanceof PersonaResolutionError) {
</file context>

} catch (err) {
if (err instanceof PersonaResolutionError) {
die(`deploy: ${err.message}`);
}
throw err;
}
if (!resolved.path) {
die(
`deploy: persona "${selector}" resolves to the ${formatPersonaSourceLabel(resolved.source)} catalog, which has no file to deploy. ` +
'Pass a path to a persona.json or persona.ts instead.'
);
}
return resolved.path;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deploy the resolved cascade rather than the overlay file

When the selected ID is defined by a partial registry overlay, returning its declaring path discards the merged resolved.spec that made the persona valid. Deploy subsequently rereads the raw file in compileAgentSource; for example, the new test fixture containing only id and extends fails with missing top-level "intent", and other inherited deploy fields are similarly lost. The ID path needs to deploy a materialized merged spec, while preserving the declaring directory for relative handler and asset paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a higher-priority partial overlay shadows an agent persona, resolved.path points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited cloud/onEvent, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/deploy-command.ts, line 355:

<comment>When a higher-priority partial overlay shadows an agent persona, `resolved.path` points to the overlay instead of the merged agent file. Deploy then rereads only that overlay, loses inherited `cloud`/`onEvent`, and rejects the ID deployment before bundling; pass the merged registry result and the handler-owning path into deploy, or resolve the owning file before returning.</comment>

<file context>
@@ -303,6 +312,49 @@ Flags:
+        'Pass a path to a persona.json or persona.ts instead.'
+    );
+  }
+  return resolved.path;
+}
+
</file context>

}

export function parseDeployArgs(args: readonly string[]): DeployOptions {
let personaPath: string | undefined;
let mode: DeployMode | undefined;
Expand Down Expand Up @@ -373,14 +425,14 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions {
} else if (a.startsWith('--')) {
die(`deploy: unknown flag "${a}"`);
} else if (!personaPath) {
personaPath = path.resolve(a);
personaPath = resolveDeployPersonaSelector(a);
} else {
die(`deploy: unexpected positional argument "${a}"`);
}
}

if (!personaPath) {
die('deploy: missing persona path. Usage: agentworkforce deploy <persona-path>');
die('deploy: missing persona. Usage: agentworkforce deploy <persona-id|persona-path>');
}

return {
Expand Down
Loading