Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
## Behavior / risk

<!-- Required only when changing provider contracts, config/schema parsing, release/package files, or GitHub workflows.
State the user-visible behavior or compatibility/security risk. “No user-visible behavior change” is valid for an internal refactor. -->
State the user-visible behavior or compatibility/security risk. “No user-visible behavior change” is valid for an internal refactor.
No special commit-message marker or history rewrite is required. -->

## Validation

Expand Down
70 changes: 1 addition & 69 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,73 +50,6 @@ jobs:
BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: bun scripts/verify.ts full --step "${{ matrix.step }}"

policy-surface:
name: Policy surface
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
- name: Require [policy] marker when control files change
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
run: |
set -euo pipefail

# The control plane: deterministic gates and the rules that govern them.
# Editing any of these changes what every other check enforces, so the
# change must be explicit — a [policy] marker in the commit range.
patterns='
.dependency-cruiser.cjs
sgconfig.yml
biome.json
tools/architecture/
.githooks/
.claude/hooks/
.github/workflows/
scripts/lint-changed.sh
scripts/verify.ts
scripts/verify.test.ts
scripts/pr-evidence.ts
scripts/pr-evidence.test.ts
scripts/release/
scripts/setup-githooks.sh
'

base="$BASE_SHA"
# New branch / first push: no usable base; nothing to diff against.
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
echo "No base commit to compare against; skipping policy-surface check."
exit 0
fi

changed=$(git diff --name-only "$base" "$HEAD_SHA")
touched=""
for p in $patterns; do
hit=$(printf '%s\n' "$changed" | grep -F "$p" || true)
if [ -n "$hit" ]; then
touched="$touched$hit"$'\n'
fi
done

if [ -z "$touched" ]; then
echo "No control-plane files changed."
exit 0
fi

echo "Control-plane files changed:"
printf '%s' "$touched" | sed 's/^/ - /'

if git log "$base..$HEAD_SHA" --format=%B | grep -qF '[policy]'; then
echo "Found [policy] marker in commit range. OK."
exit 0
fi

echo "::error::Control-plane files changed without a [policy] marker in any commit message."
echo "Add '[policy]' to a commit message to acknowledge changing the verification harness itself."
exit 1

maintainer-evidence:
name: Maintainer evidence
runs-on: ubuntu-latest
Expand Down Expand Up @@ -157,14 +90,13 @@ jobs:
gate:
name: Gate
runs-on: ubuntu-latest
needs: [prepare-verification, verify, policy-surface, maintainer-evidence, package-compatibility]
needs: [prepare-verification, verify, maintainer-evidence, package-compatibility]
if: always()
steps:
- name: Check results
run: |
if [ "${{ needs.prepare-verification.result }}" != "success" ] || \
[ "${{ needs.verify.result }}" != "success" ] || \
[ "${{ needs.policy-surface.result }}" != "success" ] || \
[ "${{ needs.maintainer-evidence.result }}" != "success" ] || \
[ "${{ needs.package-compatibility.result }}" != "success" ]; then
echo "One or more required checks failed."
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Every PR must satisfy:
2. At least one maintainer approval
3. No unresolved review comments

We keep the contribution path open: documentation, examples, and ordinary small changes do not need a design document or a special PR label. Changes to provider contracts, configuration/schema parsing, release/package files, or GitHub workflows have a small additional requirement: fill in the **Behavior / risk** and **Validation** sections of the PR description. This lets maintainers review the contract and its evidence before reading an implementation diff.
We keep the contribution path open: documentation, examples, and ordinary small changes do not need a design document, a special PR label, or a commit-message marker. Changes to provider contracts, configuration/schema parsing, release/package files, GitHub workflows, or verification policy have a small additional requirement: fill in the **Behavior / risk** and **Validation** sections of the PR description. This lets maintainers review the contract and its evidence before reading an implementation diff without asking contributors to rewrite commit history. A generated `bun.lock` change by itself is handled by audit and compatibility checks.

Maintainers should enable the repository settings that make the same policy effective at merge time: require the `Gate` and `Analyze TypeScript` checks, require one approving review, dismiss stale approvals, require approval of the latest push, and require resolved conversations. CODEOWNERS review and a merge queue are intentionally not required for routine contributions at the current project stage.

Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/lib/build-runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export async function buildRuntimeConfig(): Promise<LoadedProjectConfig> {
// Every provider provisions a cloud sandbox environment; bailian additionally installs
// `bailian-cli` in it. The vault is provider-conditional: bailian holds its DASHSCOPE_API_KEY;
// other providers currently define no vault.
//
// Metadata stamps (`agents.base` / `agents.vault`) let the webui identify managed base
// resources via remote listing (findBaseEnvironment / findBaseVault), complementing the
// state-tracked identity the plan/apply engine provides.
const vaults = vault
? {
[vault.name]: {
Expand All @@ -45,13 +49,15 @@ export async function buildRuntimeConfig(): Promise<LoadedProjectConfig> {
secret_value: requireEnv(cred.secret_name),
...(cred.networking ? { networking: cred.networking } : {}),
})),
metadata: { "agents.vault": "true" },
},
}
: {};
const environments = {
[environment.name]: {
...(environment.description ? { description: environment.description } : {}),
config: environment.config,
metadata: { "agents.base": "true" },
},
};

Expand Down
73 changes: 66 additions & 7 deletions apps/server/src/lib/state-scope.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,75 @@
import { resolve } from "node:path";
import { copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { LocalFileStateBackend, type StateScope } from "@openagentpack/sdk";
import { RUNTIME_PROJECT_NAME } from "@/lib/build-runtime-config";

// Historical on-disk state anchor. Previously derived via deriveStatePath() from the
// demo file examples/bailian/bailian-cli/agents.yaml → agents.state.json. We pin the default
// to that exact location so previously provisioned remote_ids (agents/vault/environment)
// keep resolving and are not re-created. Override with AGENTS_STATE_PATH.
const DEFAULT_STATE_PATH = "examples/bailian/bailian-cli/agents.state.json";
/**
* Default playground state lives in the user home directory, alongside the
* provider config (~/.agents/config.json). This avoids colliding with CLI
* example configs that happen to live in the repo's examples/ directory.
*
* Override with AGENTS_STATE_PATH for custom deployment layouts.
*/
const DEFAULT_STATE_PATH = join(homedir(), ".agents", "playground.state.json");

/**
* Legacy state path. Before this migration the server stored its state inside
* the repo's example directory. We migrate it once so previously-provisioned
* remote_ids are preserved.
*/
const LEGACY_STATE_PATH = "examples/bailian/bailian-cli/agents.state.json";

let migrationChecked = false;

/**
* One-time migration: if the legacy state file exists and the new default does
* not, copy it over and log a warning. Idempotent — subsequent calls after a
* successful migration (or when no migration is needed) are no-ops. A failed
* copy allows retry on the next call so transient I/O errors don't permanently
* disable migration for the process lifetime.
*/
function ensureMigrated(newPath: string, cwd: string): void {
if (migrationChecked) return;

if (existsSync(newPath)) {
migrationChecked = true;
return;
}
const legacyPath = resolve(cwd, LEGACY_STATE_PATH);
if (!existsSync(legacyPath)) {
migrationChecked = true;
return;
}

try {
mkdirSync(dirname(newPath), { recursive: true });
copyFileSync(legacyPath, newPath);
// Rename the legacy file so a version rollback won't silently read stale state.
try {
renameSync(legacyPath, `${legacyPath}.migrated`);
} catch {
// Non-fatal: the copy succeeded, state is in the new location.
}
migrationChecked = true;
console.warn(
`[state] Migrated playground state from legacy path:\n` +
` ${legacyPath}\n` +
` → ${newPath}\n` +
` The legacy file has been renamed to ${legacyPath}.migrated.`,
);
} catch (error) {
// Don't set migrationChecked — allow retry on next call.
console.warn(`[state] Failed to migrate legacy state file: ${error instanceof Error ? error.message : error}`);
}
}

export function resolveStatePath(env: NodeJS.ProcessEnv = process.env, cwd: string = process.cwd()): string {
const configured = env.AGENTS_STATE_PATH?.trim();
return configured ? resolve(cwd, configured) : resolve(cwd, DEFAULT_STATE_PATH);
if (configured) return resolve(cwd, configured);

ensureMigrated(DEFAULT_STATE_PATH, cwd);
return DEFAULT_STATE_PATH;
}

export function deriveWebUiStateScope(): StateScope {
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/routes/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ vaultsRoute.openapi(createVaultRoute, async (c) => {
const secretValue = key ?? (primarySecret ? requireEnv(primarySecret) : "");
const vault = await withAgentRuntime(DEFAULT_AGENT_ID, (ctx) =>
createCloudVault(ctx, name, {
// display_name carries the base-vault identity (Agents/secrets) — a vault has no
// separate `name` field, so findBaseVault nets it by display_name + the stamp.
// display_name is used as the vault's human-readable label on the provider.
// findBaseVault identifies the managed vault by its metadata stamp (agents.vault).
display_name: name,
metadata,
credentials: structure.credentials.map((cred) => ({
Expand Down
5 changes: 2 additions & 3 deletions apps/server/src/schemas/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const SessionDeleteResponseSchema = z
})
.openapi("SessionDeleteResponse");

// Mode A REST request ergonomics (server-owned input, not the shared DTO).
// REST request ergonomics (server-owned input, not the shared DTO).
export const SessionsQuerySchema = z.object({
// Non-numeric values resolve to `undefined` so the handler clamps to a
// default instead of the request being rejected with a 400.
Expand Down Expand Up @@ -71,8 +71,7 @@ export const SessionParamsSchema = z.object({
export const CreateSessionBodySchema = z.object({
agentId: z.string(),
prompt: z.string().min(1),
// Required: a session must be pinned to a cloud environment (sandbox). Both transports
// enforce this so Mode A (REST/OpenAPI) and Mode B (console) reject env-less creates.
// Required: a session must be pinned to a cloud environment (sandbox).
environmentId: z.string().min(1),
// Optional: cloud vault ids to bind a user-supplied credential so the sandbox receives it.
// Top-level binding shape matches the console createSession.
Expand Down
5 changes: 2 additions & 3 deletions apps/server/src/schemas/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ export const CloudVaultsResponseSchema = z.object({
export const CreateVaultBodySchema = z.object({
name: z.string().min(1),
metadata: z.record(z.string(), z.string()).optional(),
// The DASHSCOPE_API_KEY stored as the vault's credential secret value. Optional: Mode B
// supplies the user's key; Mode A (local) omits it and the server injects it from its own
// DASHSCOPE_API_KEY env. (Mode B never reaches this REST route — it goes via console RPC.)
// The DASHSCOPE_API_KEY stored as the vault's credential secret value. Optional:
// when omitted the server injects it from its own DASHSCOPE_API_KEY env.
key: z.string().min(1).optional(),
});

Expand Down
43 changes: 39 additions & 4 deletions apps/server/src/services/agents/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DEFAULT_PLAYBOOK_PROVIDER,
getDefaultPlaybook,
getPlaybook,
getVaultProfile,
type PlaybookTemplate,
type ResolvedPlaybook,
resolvePlaybookModel,
Expand Down Expand Up @@ -80,9 +81,10 @@ export function compileAgentRuntime(
const config = cloneConfig(baseConfig);
const resolved = resolveSeedPlaybook(effectiveId, provider);
const runtimeAgentId = resolved.agent.name;
const built = buildAgentDecl(undefined, toAgentBuildInput(resolved, provider, modelOverride));
// The agent declares neither environment nor vault: base resources are provisioned eagerly
// and bound per-session via explicit environment_id + vault_ids.
const built = buildAgentDecl(undefined, toAgentBuildInput(resolved, provider, baseConfig, modelOverride));
// The agent declares environment and vault so syncAgentResources manages them
// through the plan/apply engine — giving base resources state tracking, drift
// detection, and the same content-hash identity as agent/skill resources.

config.agents = {
...(config.agents ?? {}),
Expand Down Expand Up @@ -154,12 +156,45 @@ export function computeAgentConfigHash(config: ResolvedProjectConfig, agentId: s
return createHash("sha256").update(stableStringify({ agentId, config })).digest("hex").slice(0, 16);
}

function toAgentBuildInput(resolved: ResolvedPlaybook, provider: string, modelOverride?: string): AgentBuildInput {
function toAgentBuildInput(
resolved: ResolvedPlaybook,
provider: string,
baseConfig: ResolvedProjectConfig,
modelOverride?: string,
): AgentBuildInput {
const model = resolvePlaybookModel(resolved, provider, modelOverride);
// Resolve environment and vault names from the assembled config so the compiled
// agent declares them. This lets syncAgentResources manage base resources
// through the plan/apply engine, giving them state tracking and drift detection.
// Invariant: buildRuntimeConfig produces exactly one environment (and at most one
// vault for providers that need credentials). If this assumption breaks, the agent
// would silently bind the wrong resource — fail fast instead.
const envKeys = Object.keys(baseConfig.environments ?? {});
if (envKeys.length !== 1) {
throw new Error(
`Expected exactly 1 environment in runtime config, got ${envKeys.length}. ` +
`The agent compile path assumes a single base environment per provider.`,
);
}
const environmentName = envKeys[0]!;
const vaultProfile = getVaultProfile(provider);
let vaultName: string | undefined;
if (vaultProfile) {
const vaultKeys = Object.keys(baseConfig.vaults ?? {});
if (vaultKeys.length !== 1) {
throw new Error(
`Expected exactly 1 vault in runtime config for provider '${provider}', got ${vaultKeys.length}. ` +
`The agent compile path assumes a single base vault per provider.`,
);
}
vaultName = vaultKeys[0]!;
}
return {
description: resolved.agent.description,
model,
instructions: resolved.agent.system,
environment: environmentName,
vault: vaultName,
provider,
builtinTools: resolved.agent.builtinTools,
skills: resolved.agent.skills.map((skill) =>
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/services/files/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ export async function listUserFiles(): Promise<ProviderFileInfo[]> {

/**
* Resolve a short-lived presigned download URL for a file (e.g. an agent-delivered artifact).
* Throws when the resolved provider has no download endpoint (bailian/claude); the webui's Mode A
* transport surfaces that as an error, while Mode B never calls this route.
* Throws when the resolved provider has no download endpoint (bailian/claude).
*/
export async function getUserFileDownloadUrl(id: string): Promise<{ url: string; expires_at?: string }> {
return withAgentRuntime(DEFAULT_AGENT_ID, async (ctx, compiled) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
import {
createPlaybookSessionRuntime,
getPlaybookAppId,
getSeedPlaybookAgentName,
PLAYBOOK_AGENT_NAME_PREFIX,
type RemotePlaybookAgent,
} from "@openagentpack/playbooks";
import { getPlaybookAppId, getSeedPlaybookAgentName, PLAYBOOK_AGENT_NAME_PREFIX } from "@openagentpack/playbooks";
import {
type CloudAgent,
listCloudAgents,
type ProviderSessionEvent,
readProjectRuntime,
resolveSessionProvider,
type Session,
startSessionRun,
} from "@openagentpack/sdk";
import { loadAgentRuntimeInput, withAgentRuntime } from "@/services/runtime-factory";
import { agentMetadataOf } from "./dto";
import { ensureAgentApplied } from "./provision";
import { createPlaybookSessionRuntime, type RemotePlaybookAgent } from "./runtime";
import {
attachLiveStream,
deletePlaybookSession,
Expand All @@ -26,10 +19,10 @@ import {
sendPlaybookSessionMessage,
} from "./sessions";

export type { ModeAPlaybookSessionDetail } from "./sessions";
export type { PlaybookSessionDetail } from "./sessions";

export function createModeAPlaybookSessionRuntime() {
return createPlaybookSessionRuntime<ModeAPlaybookSessionDetail, ProviderSessionEvent, Session, RemotePlaybookAgent>({
export function createServerPlaybookSessionRuntime() {
return createPlaybookSessionRuntime({
identity: {
appId: getPlaybookAppId(),
expectedAgentName: getSeedPlaybookAgentName,
Expand Down Expand Up @@ -87,12 +80,10 @@ export function createModeAPlaybookSessionRuntime() {
onDuplicateAgent({ playbookId, winner, duplicates }) {
const all = [winner, ...duplicates];
console.warn(
`玩法「${playbookId}」匹配到 ${all.length} active playbook Agent(${all
`\u73A9\u6CD5\u300C${playbookId}\u300D\u5339\u914D\u5230 ${all.length} \u4E2A active playbook Agent(${all
.map((agent) => agent.id)
.join(", ")});取最近更新的 ${winner.id}`,
.join(", ")})\uFF1B\u53D6\u6700\u8FD1\u66F4\u65B0\u7684 ${winner.id}\u3002`,
);
},
});
}

type ModeAPlaybookSessionDetail = import("./sessions").ModeAPlaybookSessionDetail;
Loading