Skip to content

Commit f2f5f03

Browse files
committed
feat(sdk): support scoped create-only agent sync
Add address-scoped planning, refresh, and execution for Agent resources. Require the target Agent to be newly created and all dependencies to be reconciled, while keeping display names independent from YAML keys.
1 parent 71d3d1c commit f2f5f03

17 files changed

Lines changed: 329 additions & 85 deletions

File tree

.changeset/calm-otters-list.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@openagentpack/sdk": minor
33
---
44

5-
Expose Bailian Managed Agents operation-level reads, cursor pagination, session events, file downloads, deployment actions, and provider capability metadata.
5+
Expose Bailian Managed Agents operation-level reads, cursor pagination, session events, file downloads, deployment actions, provider capability metadata, address-scoped Agent planning/apply with create-only safety, and Agent display names independent from logical YAML keys.

packages/sdk/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export {
3434
decideDestructive,
3535
executePlannedProject,
3636
importResource,
37+
planProjectWithStateBackend,
3738
planProjectContext,
3839
syncProjectResourcesWithStateBackend,
3940
} from "./internal/core/resource-runtime.ts";
@@ -42,6 +43,7 @@ export type {
4243
ResourceActionResult,
4344
ResourceExecutionResult,
4445
ResourcePlanResult,
46+
ResourcePlanScope,
4547
ResourceRefreshResult,
4648
ResourceRuntimeOptions,
4749
ResourceSyncRun,
@@ -209,8 +211,16 @@ export {
209211
listCloudAgents,
210212
listCloudEnvironments,
211213
listCloudVaults,
214+
planAgentResources,
215+
planAgentResourcesWithStateBackend,
212216
syncAgentResourcesWithStateBackend,
213217
} from "./internal/core/agent-runtime.ts";
218+
export type {
219+
AgentResourcePlan,
220+
AgentResourcePlanOptions,
221+
AgentResourceSyncMode,
222+
AgentResourceSyncOptions,
223+
} from "./internal/core/agent-runtime.ts";
214224

215225
export type { CollectedSessionEvents } from "./internal/core/session-runtime.ts";
216226
export {

packages/sdk/src/internal/core/agent-builder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface AgentMcpBuildInput {
2020
}
2121

2222
export interface AgentBuildInput {
23+
name?: string;
2324
description?: string;
2425
model?: AgentDecl["model"];
2526
instructions?: string;
@@ -79,6 +80,7 @@ export function buildAgentDecl(base: AgentDecl | undefined, input: AgentBuildInp
7980

8081
const agent: AgentDecl = {
8182
...(base ?? {}),
83+
name: input.name ?? base?.name,
8284
description: input.description ?? base?.description,
8385
model,
8486
instructions: input.instructions ?? base?.instructions ?? "",

packages/sdk/src/internal/core/agent-runtime.ts

Lines changed: 66 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { UserError } from "../errors.ts";
2+
import { buildDependencyGraph, collectDependencyClosure } from "../graph/dependency.ts";
23
import type { RemoteResource } from "../providers/interface.ts";
34
import type { ResourceCrudAdapter } from "../providers/resource-workflow.ts";
45
import { buildSessionBindings, resolveSessionProvider } from "../session/session-manager.ts";
@@ -21,7 +22,7 @@ import type { ResourceAddress } from "../types/state.ts";
2122
import { addressKey } from "../types/state.ts";
2223
import { resolveAgentMaterialization } from "./agent-materialization.ts";
2324
import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts";
24-
import { getRuntimeProvider, writeProjectRuntime } from "./project-runtime.ts";
25+
import { getRuntimeProvider, readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts";
2526
import {
2627
type DestructivePolicy,
2728
decideDestructive,
@@ -57,8 +58,11 @@ export interface AgentResourcePlan {
5758
export interface AgentResourcePlanOptions {
5859
refresh?: boolean;
5960
quiet?: boolean;
61+
mode?: AgentResourceSyncMode;
6062
}
6163

64+
export type AgentResourceSyncMode = "reconcile" | "create-only";
65+
6266
export interface AgentResourceSyncOptions extends AgentResourcePlanOptions {
6367
policy?: DestructivePolicy;
6468
confirm?: (actions: PlannedAction[]) => boolean | Promise<boolean>;
@@ -246,22 +250,48 @@ export async function planAgentResources(
246250
options: AgentResourcePlanOptions = {},
247251
): Promise<AgentResourcePlan> {
248252
const agent = getAgent(ctx, agentId);
249-
const planned = await planProjectContext(ctx, {
253+
const rootAddress = collectAgentAddresses(ctx.config, agent.agentName, agent.provider)[0]!;
254+
let planned = await planProjectContext(ctx, {
250255
provider: agent.provider,
256+
scope: { roots: [rootAddress] },
251257
refresh: options.refresh,
252258
quiet: options.quiet ?? true,
253259
});
254-
const actions = filterAgentActions(ctx, agent, planned.plan);
260+
const actions = planned.plan.actions;
261+
let diagnostics = planned.plan.diagnostics;
262+
if (options.mode === "create-only") {
263+
const blockedReason = getCreateOnlyBlockedReason(planned, rootAddress);
264+
if (blockedReason) {
265+
diagnostics = [
266+
...diagnostics,
267+
{
268+
severity: "error",
269+
code: "agent.create_only.blocked",
270+
message: blockedReason,
271+
resource: rootAddress,
272+
},
273+
];
274+
planned = replaceResourcePlan(planned, { ...planned.plan, diagnostics });
275+
}
276+
}
255277
return {
256278
agentId,
257279
provider: agent.provider,
258280
actions,
259-
diagnostics: filterAgentDiagnostics(ctx, agent, planned.plan),
281+
diagnostics,
260282
destructiveActions: selectDestructive(actions),
261283
planned,
262284
};
263285
}
264286

287+
export async function planAgentResourcesWithStateBackend(
288+
input: BackendRuntimeInput,
289+
agentId: string,
290+
options: AgentResourcePlanOptions = {},
291+
): Promise<AgentResourcePlan> {
292+
return readProjectRuntime(input, (ctx) => planAgentResources(ctx, agentId, options));
293+
}
294+
265295
export async function syncAgentResources(
266296
ctx: ProjectRuntimeContext,
267297
agentId: string,
@@ -278,6 +308,7 @@ async function runAgentSync(
278308
const fullPlan = await planAgentResources(ctx, agentId, {
279309
refresh: options.refresh,
280310
quiet: options.quiet,
311+
mode: options.mode,
281312
});
282313
const actions = fullPlan.actions;
283314
const destructiveActions = fullPlan.destructiveActions;
@@ -317,11 +348,7 @@ async function runAgentSync(
317348
}
318349

319350
try {
320-
const scopedPlan = {
321-
diagnostics: fullPlan.diagnostics,
322-
actions: scopePlanActions(fullPlan.planned.plan, actions),
323-
};
324-
const execution = await executePlannedProject(replaceResourcePlan(fullPlan.planned, scopedPlan), {
351+
const execution = await executePlannedProject(fullPlan.planned, {
325352
policy: "force",
326353
});
327354
const results = toAgentSyncResults(execution);
@@ -359,6 +386,29 @@ export async function syncAgentResourcesWithStateBackend(
359386
return writeProjectRuntime(input, (ctx) => syncAgentResources(ctx, agentId, options));
360387
}
361388

389+
function getCreateOnlyBlockedReason(planned: ResourcePlanResult, rootAddress: ResourceAddress): string | undefined {
390+
const refreshError = planned.refreshResult?.errors[0];
391+
if (refreshError) {
392+
return `Cannot verify Agent dependencies because refresh failed for ${addressKey(refreshError.resource.address)}: ${refreshError.error}`;
393+
}
394+
395+
const rootKey = addressKey(rootAddress);
396+
const rootAction = planned.plan.actions.find((action) => addressKey(action.address) === rootKey);
397+
if (!rootAction) return `Scoped plan did not contain the target Agent ${rootKey}.`;
398+
if (rootAction.action !== "create") {
399+
return `Target Agent ${rootKey} must be a new resource, but the scoped plan requires '${rootAction.action}'.`;
400+
}
401+
402+
const dependencyChanges = planned.plan.actions.filter(
403+
(action) => addressKey(action.address) !== rootKey && action.action !== "no-op",
404+
);
405+
if (dependencyChanges.length > 0) {
406+
const labels = dependencyChanges.map((action) => `${addressKey(action.address)} (${action.action})`).join(", ");
407+
return `Agent create requires every declared dependency to be up-to-date. Reconcile these resources first: ${labels}.`;
408+
}
409+
return undefined;
410+
}
411+
362412
export function toAgentSyncResults(execution: ResourceExecutionResult): AgentSyncResult[] {
363413
return execution.results.map((result) => ({
364414
action: result.action,
@@ -443,36 +493,6 @@ export function getAgentReadinessFromPlan(
443493
};
444494
}
445495

446-
function filterAgentActions(ctx: ProjectRuntimeContext, agent: AgentDefinition, plan: ExecutionPlan): PlannedAction[] {
447-
const relevantKeys = agentAddressKeys(ctx, agent);
448-
return plan.actions.filter(
449-
(action) =>
450-
relevantKeys.has(addressKey(action.address)) ||
451-
action.dependencies.some((dependency) => relevantKeys.has(addressKey(dependency))),
452-
);
453-
}
454-
455-
function filterAgentDiagnostics(ctx: ProjectRuntimeContext, agent: AgentDefinition, plan: ExecutionPlan): Diagnostic[] {
456-
const relevantKeys = agentAddressKeys(ctx, agent);
457-
return plan.diagnostics.filter(
458-
(diagnostic) => !diagnostic.resource || relevantKeys.has(addressKey(diagnostic.resource)),
459-
);
460-
}
461-
462-
function agentAddressKeys(ctx: ProjectRuntimeContext, agent: AgentDefinition): Set<string> {
463-
return new Set(collectAgentAddresses(ctx.config, agent.agentName, agent.provider).map(addressKey));
464-
}
465-
466-
function scopePlanActions(fullPlan: ExecutionPlan, agentActions: PlannedAction[]): PlannedAction[] {
467-
const allowedKeys = new Set(agentActions.filter((action) => action.action !== "no-op").map(actionKey));
468-
return fullPlan.actions.filter((action) => action.action === "no-op" || allowedKeys.has(actionKey(action)));
469-
}
470-
471-
function actionKey(action: PlannedAction): string {
472-
const address = action.address;
473-
return `${action.action}:${address.provider}:${address.type}:${address.name}`;
474-
}
475-
476496
function isNonBlockingAgentDrift(action: PlannedAction): boolean {
477497
if (action.action === "no-op") return true;
478498
return action.readinessImpact === "non_blocking";
@@ -485,35 +505,13 @@ export function collectAgentAddresses(config: ProjectConfig, agentName: string,
485505
}
486506
const resolvedProvider = provider ?? resolveSessionProvider(agentName, config, undefined);
487507
const materialization = resolveAgentMaterialization(resolvedProvider, agent);
488-
const addresses: ResourceAddress[] = [
489-
{ type: materialization.resourceType, name: agentName, provider: resolvedProvider },
490-
];
491-
492-
if (agent.environment) {
493-
addresses.push({
494-
type: "environment",
495-
name: agent.environment,
496-
provider: resolvedProvider,
497-
});
498-
}
499-
if (agent.vault) {
500-
addresses.push({ type: "vault", name: agent.vault, provider: resolvedProvider });
501-
}
502-
for (const name of agent.memory_stores ?? []) {
503-
addresses.push({ type: "memory_store", name, provider: resolvedProvider });
504-
}
505-
for (const skill of agent.skills ?? []) {
506-
if (typeof skill === "string") {
507-
addresses.push({ type: "skill", name: skill, provider: resolvedProvider });
508-
}
509-
}
510-
for (const subAgent of agent.multiagent?.agents ?? []) {
511-
const subDecl = config.agents?.[subAgent];
512-
const subType = subDecl ? resolveAgentMaterialization(resolvedProvider, subDecl).resourceType : "agent";
513-
addresses.push({ type: subType, name: subAgent, provider: resolvedProvider });
514-
}
515-
516-
return addresses;
508+
const rootAddress: ResourceAddress = {
509+
type: materialization.resourceType,
510+
name: agentName,
511+
provider: resolvedProvider,
512+
};
513+
const graph = buildDependencyGraph(config, [resolvedProvider]);
514+
return collectDependencyClosure(graph, [rootAddress]);
517515
}
518516

519517
function toAgentDefinition(config: ProjectConfig, agentName: string, agent: AgentDecl): AgentDefinition {

packages/sdk/src/internal/core/resource-runtime.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { UserError } from "../errors.ts";
22
import { type ExecutionResult, executePlan } from "../executor/executor.ts";
3+
import { buildDependencyGraph, collectDependencyClosure } from "../graph/dependency.ts";
34
import { getResourceDeclaration } from "../planner/declaration.ts";
45
import { buildReadinessBaseline } from "../planner/plan-semantics.ts";
56
import { buildPlan } from "../planner/planner.ts";
@@ -8,18 +9,25 @@ import { readComparableIfSupported } from "../providers/drift-support.ts";
89
import type { ExecutionPlan, PlannedAction } from "../types/plan.ts";
910
import type { RuntimeFeedbackSink } from "../types/runtime-feedback.ts";
1011
import type { ResourceAddress, ResourceState, ResourceType } from "../types/state.ts";
12+
import { addressKey } from "../types/state.ts";
1113
import { contentHash as stableContentHash } from "../utils/hash.ts";
1214
import type { BackendRuntimeInput, ProjectRuntimeContext } from "./project-runtime.ts";
1315
import { readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts";
1416

1517
export interface ResourceRuntimeOptions extends DestructiveDecisionOptions {
1618
provider?: string;
19+
scope?: ResourcePlanScope;
1720
refresh?: boolean;
1821
refreshOnly?: boolean;
1922
quiet?: boolean;
2023
onFeedback?: RuntimeFeedbackSink;
2124
}
2225

26+
export interface ResourcePlanScope {
27+
roots: ResourceAddress[];
28+
includeDependencies?: boolean;
29+
}
30+
2331
export interface ResourceRefreshResult {
2432
removed: ResourceState[];
2533
errors: Array<{ resource: ResourceState; error: string }>;
@@ -42,6 +50,7 @@ export interface ResourcePlanResult {
4250
refreshResult?: ResourceRefreshResult;
4351
targetProviders?: string[];
4452
destructiveActions: PlannedAction[];
53+
selectedAddresses?: ResourceAddress[];
4554
}
4655

4756
export type DestructivePolicy = "block" | "prompt" | "force";
@@ -164,11 +173,17 @@ export async function planProjectContext(
164173
ctx: ProjectRuntimeContext,
165174
options: ResourceRuntimeOptions = {},
166175
): Promise<ResourcePlanResult> {
167-
const targetProviders = resolveTargetProviders(options.provider);
176+
let targetProviders = resolveTargetProviders(options.provider);
177+
if (!targetProviders && options.scope) {
178+
targetProviders = [...new Set(options.scope.roots.map((root) => root.provider))];
179+
}
180+
const selectedAddresses = options.scope ? resolvePlanScope(ctx, targetProviders ?? [], options.scope) : undefined;
181+
const resourceKeys = selectedAddresses ? new Set(selectedAddresses.map(addressKey)) : undefined;
168182
const refreshResult =
169183
options.refresh !== false && ctx.state.listResources().length > 0
170184
? await refreshState(ctx.state, ctx.providers, {
171185
targetProviders,
186+
resourceKeys,
172187
config: ctx.config,
173188
quiet: options.quiet ?? true,
174189
onFeedback: options.onFeedback,
@@ -178,6 +193,7 @@ export async function planProjectContext(
178193
const plan = await buildPlan(ctx.config, ctx.state.getStateFile(), {
179194
providers: targetProviders,
180195
configPath: ctx.configPath,
196+
resourceAddresses: selectedAddresses,
181197
});
182198

183199
return {
@@ -186,9 +202,34 @@ export async function planProjectContext(
186202
refreshResult: toResourceRefreshResult(refreshResult),
187203
targetProviders,
188204
destructiveActions: selectDestructive(plan.actions),
205+
selectedAddresses,
189206
};
190207
}
191208

209+
function resolvePlanScope(
210+
ctx: ProjectRuntimeContext,
211+
targetProviders: string[],
212+
scope: ResourcePlanScope,
213+
): ResourceAddress[] {
214+
if (scope.roots.length === 0) {
215+
throw new UserError("Resource plan scope requires at least one root address.");
216+
}
217+
for (const root of scope.roots) {
218+
if (!targetProviders.includes(root.provider)) {
219+
throw new UserError(
220+
`Scoped resource ${addressKey(root)} is outside the selected provider set: ${targetProviders.join(", ")}.`,
221+
);
222+
}
223+
}
224+
const graph = buildDependencyGraph(ctx.config, targetProviders);
225+
for (const root of scope.roots) {
226+
if (!graph.nodes.has(addressKey(root))) {
227+
throw new UserError(`Scoped resource ${addressKey(root)} is not declared in the project config.`);
228+
}
229+
}
230+
return scope.includeDependencies === false ? [...scope.roots] : collectDependencyClosure(graph, scope.roots);
231+
}
232+
192233
export async function executePlannedProject(
193234
planned: ResourcePlanResult,
194235
options: DestructiveDecisionOptions & {

packages/sdk/src/internal/graph/dependency.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ export interface DependencyGraph {
1111
edges: Map<string, Set<string>>;
1212
}
1313

14+
/** Resolve explicit resource roots plus every declared transitive dependency. */
15+
export function collectDependencyClosure(graph: DependencyGraph, roots: readonly ResourceAddress[]): ResourceAddress[] {
16+
const selected = new Map<string, ResourceAddress>();
17+
18+
function visit(address: ResourceAddress): void {
19+
const key = addressKey(address);
20+
if (selected.has(key)) return;
21+
selected.set(key, graph.nodes.get(key) ?? address);
22+
for (const dependencyKey of graph.edges.get(key) ?? []) {
23+
const dependency = graph.nodes.get(dependencyKey);
24+
if (dependency) visit(dependency);
25+
}
26+
}
27+
28+
for (const root of roots) visit(root);
29+
return [...selected.values()];
30+
}
31+
1432
export function buildDependencyGraph(config: ProjectConfig, targetProviders: string[]): DependencyGraph {
1533
const nodes = new Map<string, ResourceAddress>();
1634
const edges = new Map<string, Set<string>>();

0 commit comments

Comments
 (0)