Skip to content

Commit f919eba

Browse files
lishengzxcclaude
andcommitted
feat(dsh): remote managed-agent as on-demand tool + bl managed-agent run
Rework the managed-agent integration so a dsh user can, in plain language, have a Bailian cloud agent created and run a task — no hand-written agents.yaml, no prior apply. New `bl managed-agent run --prompt <task> [--instructions] [--model] [--agent]`: one step that idempotently materializes a cloud agent + its environment, then opens a session and streams the result. It mirrors the OpenAgentPack webui backend's ensure+run recipe (resolveProjectConfigFrom Object → syncAgentResourcesWithStateBackend → readProjectRuntime + startSessionRun) from an in-memory config, reusing the existing credential spine in _engine/credentials.ts. State persists under the bl config dir (~/.bailian/managed-agent/<agent>/), never the user's cwd, so repeat runs with the same --agent reuse the materialized agent. Unlike apply it provisions without --yes, since running is the intent. dsh side: replace the SubagentProvider with a plain tool `bailian_run_remote_task` (packages/dsh/src/tool-managed-agent). The subagent seam did not fit: in the web profile every tool-subagent row is disabled in the host plane (delegation lives in agent presets), a provider fixes one agent identity in config, and the default numeric maxDepth would fail-mount a no-depthLimit provider. As a tool the model calls it directly and fills `instructions` from the user's intent, so the remote agent's role is defined per task. Enabled by default — it creates nothing at load, only on invocation. LLM row: configure the base bundle's existing llm-pi-ai row instead of mounting a second pi-ai instance (a second instance re-declares pi-ai's global configurable-provider catalog and fails boot on a duplicate amazon-bedrock). TokenPlan reads a dedicated BAILIAN_TOKENPLAN_API_KEY, not DASHSCOPE_API_KEY: TokenPlan (sk-sp-) and pay-as-you-go (sk-ws-) keys 401 each other's endpoints, so sharing one var would silently break whichever plugin lost. Note: the ensure+run happy path could not be verified end-to-end on the available account — agentstudio returns 404 there, and the existing `managed-agent apply` 404s identically against the same endpoint/key, so the failure is account/service provisioning, not this change. Command wiring, dry-run, config assembly, credential injection and URL construction were all verified. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9e9911a commit f919eba

13 files changed

Lines changed: 519 additions & 294 deletions

File tree

packages/cli/src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
managedAgentValidate,
103103
managedAgentPlan,
104104
managedAgentApply,
105+
managedAgentRun,
105106
managedAgentDestroy,
106107
managedAgentStateList,
107108
managedAgentStateShow,
@@ -225,6 +226,7 @@ export const commands: Record<string, AnyCommand> = {
225226
"managed-agent validate": managedAgentValidate,
226227
"managed-agent plan": managedAgentPlan,
227228
"managed-agent apply": managedAgentApply,
229+
"managed-agent run": managedAgentRun,
228230
"managed-agent destroy": managedAgentDestroy,
229231
"managed-agent state list": managedAgentStateList,
230232
"managed-agent state show": managedAgentStateShow,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { mkdirSync } from "node:fs";
2+
import { dirname, join } from "node:path";
3+
import {
4+
type BackendRuntimeInput,
5+
LocalFileStateBackend,
6+
resolveProjectConfigFromObject,
7+
} from "@openagentpack/sdk";
8+
import { getConfigDir } from "bailian-cli-core";
9+
import {
10+
assertProviderCredentials,
11+
type CredentialHost,
12+
injectProviderCredentials,
13+
normalizeInterpolatedProviderBlocks,
14+
prepareProviderEnv,
15+
scrubCredentialEnv,
16+
} from "./credentials.ts";
17+
import { type HostContext, installSdkTransport } from "./transport.ts";
18+
19+
/** Default agent identity `bl managed-agent run` materializes and reuses. */
20+
export const DEFAULT_INLINE_AGENT = "dsh-remote-runner";
21+
22+
/** Default model for the materialized agent. */
23+
export const DEFAULT_INLINE_MODEL = "qwen3.8-max";
24+
25+
/** Default role when the caller supplies no `--instructions`. */
26+
export const DEFAULT_INLINE_INSTRUCTIONS = "You are a helpful assistant. Complete the task.";
27+
28+
/** Environment name declared in the inline config; one cloud env per agent. */
29+
const INLINE_ENVIRONMENT = "cloud";
30+
31+
export interface InlineAgentOptions {
32+
agentName: string;
33+
instructions: string;
34+
model: string;
35+
/** Override the persisted state location (defaults under the bl config dir). */
36+
statePath?: string;
37+
}
38+
39+
/**
40+
* Slugify an agent name into a filesystem- and project-id-safe token. The state
41+
* for each distinct agent lives in its own directory so repeat runs reuse the
42+
* same materialized remote agent.
43+
*/
44+
function slugify(agentName: string): string {
45+
const slug = agentName
46+
.toLowerCase()
47+
.replace(/[^a-z0-9._-]+/g, "-")
48+
.replace(/^-+|-+$/g, "");
49+
return slug.length > 0 ? slug : "agent";
50+
}
51+
52+
/** Where a materialized agent's state is persisted (not the user's cwd). */
53+
export function inlineStatePath(agentName: string): string {
54+
return join(getConfigDir(), "managed-agent", slugify(agentName), "state.json");
55+
}
56+
57+
/**
58+
* The minimal in-memory project config that materializes into one cloud agent.
59+
* `providers.bailian` carries empty `api_key`/`base_url` placeholders so
60+
* {@link injectProviderCredentials} fills them from bl's auth chain (it only
61+
* writes fields the block already declares).
62+
*/
63+
export function buildInlineConfig(opts: InlineAgentOptions): Record<string, unknown> {
64+
return {
65+
version: "1",
66+
providers: {
67+
bailian: { api_key: "", base_url: "" },
68+
},
69+
defaults: { provider: "bailian" },
70+
environments: {
71+
[INLINE_ENVIRONMENT]: {
72+
description: "Bailian CLI cloud environment",
73+
config: { type: "cloud", networking: { type: "unrestricted" } },
74+
},
75+
},
76+
agents: {
77+
[opts.agentName]: {
78+
description: opts.agentName,
79+
model: opts.model,
80+
instructions: opts.instructions,
81+
environment: INLINE_ENVIRONMENT,
82+
provider: "bailian",
83+
},
84+
},
85+
};
86+
}
87+
88+
/**
89+
* Build the `BackendRuntimeInput` shared by ensure (`syncAgentResourcesWith
90+
* StateBackend`) and run (`readProjectRuntime` + `startSessionRun`). Mirrors the
91+
* credential spine of {@link buildAgentRuntime} but sources config from an
92+
* in-memory object instead of a file, so no `agents.yaml` or `apply` is required.
93+
*/
94+
export async function buildInlineBackendInput(
95+
host: HostContext & CredentialHost,
96+
opts: InlineAgentOptions,
97+
): Promise<BackendRuntimeInput> {
98+
installSdkTransport(host);
99+
prepareProviderEnv();
100+
101+
const rawConfig = buildInlineConfig(opts);
102+
const { config, projectName } = await resolveProjectConfigFromObject(rawConfig, {
103+
projectName: slugify(opts.agentName),
104+
});
105+
106+
normalizeInterpolatedProviderBlocks(config.providers);
107+
injectProviderCredentials(config.providers, host);
108+
scrubCredentialEnv();
109+
assertProviderCredentials(config.providers);
110+
111+
const statePath = opts.statePath ?? inlineStatePath(opts.agentName);
112+
mkdirSync(dirname(statePath), { recursive: true });
113+
const stateBackend = new LocalFileStateBackend({ statePath });
114+
115+
return {
116+
projectName,
117+
config,
118+
stateBackend,
119+
stateScope: { projectId: slugify(opts.agentName) },
120+
providers: config.providers,
121+
};
122+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {
2+
BailianError,
3+
defineCommand,
4+
detectOutputFormat,
5+
ExitCode,
6+
type FlagsDef,
7+
} from "bailian-cli-core";
8+
import { emitResult } from "bailian-cli-runtime";
9+
import {
10+
readProjectRuntime,
11+
startSessionRun,
12+
startSessionRunPolling,
13+
syncAgentResourcesWithStateBackend,
14+
} from "@openagentpack/sdk";
15+
import { CREDENTIALS_NOTE } from "./_engine/config-loader.ts";
16+
import { withStdoutProtected } from "./_engine/console-capture.ts";
17+
import { withAgentErrors } from "./_engine/errors.ts";
18+
import {
19+
buildInlineBackendInput,
20+
DEFAULT_INLINE_AGENT,
21+
DEFAULT_INLINE_INSTRUCTIONS,
22+
DEFAULT_INLINE_MODEL,
23+
} from "./_engine/inline-runtime.ts";
24+
import { renderCollectedEvents, streamAndRenderEvents } from "./_engine/session-render.ts";
25+
26+
const RUN_FLAGS = {
27+
prompt: {
28+
type: "string",
29+
valueHint: "<text>",
30+
description: "Task to run (required)",
31+
required: true,
32+
},
33+
instructions: {
34+
type: "string",
35+
valueHint: "<text>",
36+
description: "Role/system instructions for the remote agent (default: generic assistant)",
37+
},
38+
model: {
39+
type: "string",
40+
valueHint: "<id>",
41+
description: `Model for the remote agent (default: ${DEFAULT_INLINE_MODEL})`,
42+
},
43+
agent: {
44+
type: "string",
45+
valueHint: "<name>",
46+
description: `Agent identity to create/reuse (default: ${DEFAULT_INLINE_AGENT})`,
47+
},
48+
noStream: {
49+
type: "switch",
50+
description: "Use polling instead of SSE streaming",
51+
},
52+
} satisfies FlagsDef;
53+
54+
export default defineCommand({
55+
description: "Provision (if needed) a cloud agent and run a task in one step",
56+
auth: "apiKey",
57+
usageArgs: "--prompt <text> [--instructions <text>] [--model <id>] [--agent <name>]",
58+
flags: RUN_FLAGS,
59+
exampleArgs: [
60+
'--prompt "Summarize the latest AI news"',
61+
'--prompt "Audit this dependency tree" --instructions "You are a security expert" --model qwen3.8-max',
62+
],
63+
notes: [
64+
...CREDENTIALS_NOTE,
65+
"Unlike `apply`, this creates/updates the cloud agent + environment on demand without --yes. The first run provisions cloud resources (may incur cost and take longer to start); later runs with the same --agent reuse them.",
66+
],
67+
async run(ctx) {
68+
const { settings, flags } = ctx;
69+
const format = detectOutputFormat(settings.output);
70+
const asJson = format === "json";
71+
72+
const agentName = flags.agent ?? DEFAULT_INLINE_AGENT;
73+
const model = flags.model ?? DEFAULT_INLINE_MODEL;
74+
const instructions = flags.instructions ?? DEFAULT_INLINE_INSTRUCTIONS;
75+
76+
if (settings.dryRun) {
77+
emitResult(
78+
{
79+
would_run: {
80+
prompt: flags.prompt,
81+
agent: agentName,
82+
model,
83+
instructions,
84+
mode: flags.noStream ? "polling" : "streaming",
85+
},
86+
},
87+
format,
88+
);
89+
return;
90+
}
91+
92+
await withAgentErrors(() =>
93+
withStdoutProtected(async () => {
94+
const input = await buildInlineBackendInput(ctx, { agentName, instructions, model });
95+
96+
// Ensure the remote agent + its cloud environment exist. Idempotent:
97+
// a repeat run with the same agent name reuses the materialized state.
98+
if (!asJson) process.stderr.write(`Ensuring cloud agent "${agentName}"…\n`);
99+
const sync = await syncAgentResourcesWithStateBackend(input, agentName, {
100+
policy: "force",
101+
quiet: true,
102+
});
103+
if (sync.status !== "completed") {
104+
const detail =
105+
sync.error ??
106+
sync.diagnostics.find((diag) => diag.severity === "error")?.message ??
107+
`provisioning ended with status "${sync.status}"`;
108+
throw new BailianError(
109+
`Failed to provision cloud agent "${agentName}": ${detail}`,
110+
ExitCode.GENERAL,
111+
);
112+
}
113+
114+
// Run the task inside a runtime bound to the just-materialized state.
115+
await readProjectRuntime(input, async (runtime) => {
116+
if (flags.noStream) {
117+
const run = await startSessionRunPolling(runtime, flags.prompt, { agent: agentName });
118+
if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`);
119+
renderCollectedEvents(run, asJson, {
120+
session_id: run.session.id,
121+
provider: run.provider,
122+
agent: run.agentName,
123+
});
124+
} else {
125+
const run = await startSessionRun(runtime, flags.prompt, { agent: agentName });
126+
if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`);
127+
await streamAndRenderEvents(run.events, asJson, {
128+
session_id: run.session.id,
129+
provider: run.provider,
130+
agent: run.agentName,
131+
});
132+
}
133+
});
134+
}),
135+
);
136+
},
137+
});

packages/commands/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export { default as managedAgentInit } from "./commands/managed-agent/init.ts";
9999
export { default as managedAgentValidate } from "./commands/managed-agent/validate.ts";
100100
export { default as managedAgentPlan } from "./commands/managed-agent/plan.ts";
101101
export { default as managedAgentApply } from "./commands/managed-agent/apply.ts";
102+
export { default as managedAgentRun } from "./commands/managed-agent/run.ts";
102103
export { default as managedAgentDestroy } from "./commands/managed-agent/destroy.ts";
103104
export { default as managedAgentStateList } from "./commands/managed-agent/state-list.ts";
104105
export { default as managedAgentStateShow } from "./commands/managed-agent/state-show.ts";

packages/commands/tests/e2e/topic-routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
177177
"managed-agent validate": "managedAgentValidate",
178178
"managed-agent plan": "managedAgentPlan",
179179
"managed-agent apply": "managedAgentApply",
180+
"managed-agent run": "managedAgentRun",
180181
"managed-agent destroy": "managedAgentDestroy",
181182
"managed-agent state list": "managedAgentStateList",
182183
"managed-agent state rm": "managedAgentStateRm",

0 commit comments

Comments
 (0)