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
15 changes: 8 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions docs/guides/deploy-to-qoder.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ providers:

A `deployment run` on Qoder creates a native Deployment Run and associated Session. Cron schedules run server-side.

## Runtime environment variables

Declare `environment_variables` on an Agent to inject variables into its Qoder runtime:

```yaml
agents:
assistant:
model: { qoder: auto }
instructions: Help the user.
environment: dev
environment_variables:
FEATURE_FLAG: "on"
LOG_LEVEL: debug
```

OpenAgentPack maps this to each Qoder API's native shape: a top-level object on Forward Templates, `config.environment_variables` on Forward Sessions, and the required `KEY=VALUE;...` string on managed Sessions. Other providers reject this Qoder-specific field during validation.

## Tool naming

Qoder uses PascalCase tool names natively (`Read`, `Glob`, `Grep`, `WebFetch`, `WebSearch`, `Write`, `Edit`, `Bash`). Write tools **lowercase** in config and OpenAgentPack converts them automatically when applying to Qoder — this keeps the same config portable to Bailian, Claude, and Volcengine Ark.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ agents:
skills: [ <string> | { type, skill_id, version? } ]
vault: <string>
memory_stores: [ <string> ]
environment_variables: { <key>: <string> } # Qoder only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
Expand All @@ -266,6 +267,7 @@ agents:
| `skills[]` | string \| AgentSkillRef | no | Skill name or `{ type: "official"\|"custom", skill_id, version? }`. |
| `vault` | string | no | Vault name. |
| `memory_stores` | string[] | no | Bound memory stores. |
| `environment_variables` | map<string,string> | no | Qoder runtime variables. Managed Sessions use Qoder's `KEY=VALUE;...` wire format; Forward Templates store the map as defaults and Forward Sessions send it under `config.environment_variables`. |
| `resources` | SessionResource[] | no | Resources attached to every managed Session created for the Agent. |
| `multiagent.type` | `"coordinator"` | no | Declare a coordinator agent. |
| `multiagent.agents` | string[] | yes (with multiagent) | Agents it orchestrates. |
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,16 @@
"@changesets/cli": "^2.31.0",
"@types/bun": "^1.3.14",
"dependency-cruiser": "^17.4.3",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"tsup": "^8.5.1",
"typescript": "^6.0.3"
},
"overrides": {
"brace-expansion": "5.0.8",
"brace-expansion": "5.0.9",
"esbuild": "0.28.1",
"fast-equals": "5.3.3",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"nanoid": "3.3.17",
"postcss": "8.5.23"
},
"trustedDependencies": [
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/core/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export async function createSessionForAgent(
resources: options.resources,
title: options.title,
metadata: options.metadata,
environmentVariables: options.environmentVariables,
});
const session = await adapter.createSession(bindings);
return { agentName, provider, session };
Expand All @@ -178,6 +179,7 @@ export async function startSessionRun(
resources: options.resources,
title: options.title,
metadata: options.metadata,
environmentVariables: options.environmentVariables,
});
const session = await adapter.createSession(bindings);
return {
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,13 @@ export function collectProviderCapabilities(
}
}
for (const [name, agent] of Object.entries(config.agents ?? {})) {
if (agent.environment_variables && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.environment_variables.unsupported`,
`agent.${name}: environment_variables is supported only by Qoder; remove it or pin this agent to qoder.`,
{ type: "agent", name, provider: providerName },
);
}
if (agent.tunnel && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.tunnel.unsupported`,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/internal/parser/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ const agentSchema = z.object({
resources: z.array(sessionGithubRepoResourceSchema).optional(),
multiagent: multiagentSchema.optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.record(z.string().min(1), z.string()).optional(),
delivery: z.record(z.string(), agentDeliverySchema).optional(),
});

Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,9 @@ export class QoderAdapter implements ProviderAdapter {
};
if (bindings.title) body.title = bindings.title;
if (bindings.metadata) body.metadata = bindings.metadata;
if (bindings.environment_variables) {
body.config = { environment_variables: bindings.environment_variables };
}
if (bindings.files?.length) {
body.resources = bindings.files.map((file) => ({
type: "file",
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/src/internal/providers/qoder/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ export function mapForwardTemplate(
if (refs.tunnel_id) body.tunnel_id = refs.tunnel_id;
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
else body.metadata = decl.metadata ?? {};
if (decl.environment_variables) body.environment_variables = decl.environment_variables;

if (decl.tools) {
body.tools = [
Expand Down Expand Up @@ -650,6 +651,11 @@ export function mapSession(bindings: ManagedSessionBindings): unknown {
if (bindings.tunnel_id) body.tunnel_id = bindings.tunnel_id;
if (bindings.title) body.title = bindings.title;
if (bindings.metadata) body.metadata = bindings.metadata;
if (bindings.environment_variables) {
body.environment_variables = Object.entries(bindings.environment_variables)
.map(([key, value]) => `${key}=${value}`)
.join(";");
}
if (bindings.vault_ids.length) body.vault_ids = bindings.vault_ids;
// Memory stores and user-uploaded files share the `resources` array (vaults are separate
// via `vault_ids`). Every entry needs a non-empty `type`; file shape mirrors qoder's
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/src/internal/session/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface SessionCreateOptions {
title?: string;
provider?: string;
metadata?: Record<string, string>;
/** Session-level environment variables. Currently supported by Qoder. */
environmentVariables?: Record<string, string>;
}

export function resolveSessionProvider(agentName: string, config: ProjectConfig, overrideProvider?: string): string {
Expand Down Expand Up @@ -71,6 +73,10 @@ export function buildSessionBindings(
throw new UserError(`Agent '${agentName}' not found in config. Available agents: ${available || "(none)"}`);
}
const sessionResources = options.resources ?? agent.resources;
const environmentVariables = options.environmentVariables ?? agent.environment_variables;
if (environmentVariables && provider !== "qoder") {
throw new UserError("Session environment variables are supported only by Qoder.");
}
const providerFeatures = getProvider(provider)?.features;
for (const resource of sessionResources ?? []) {
if (!providerFeatures?.session_resources.includes(resource.type)) {
Expand Down Expand Up @@ -102,6 +108,7 @@ export function buildSessionBindings(
files: (options.files ?? []).map((file) => ({ file_id: file.fileId, mount_path: file.mountPath })),
title: options.title,
metadata: options.metadata,
environment_variables: environmentVariables,
};
}

Expand Down Expand Up @@ -157,6 +164,7 @@ export function buildSessionBindings(
resources: sessionResources,
title: options.title,
metadata: options.metadata,
environment_variables: environmentVariables,
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ export interface AgentDecl {
resources?: SessionResourceDecl[];
multiagent?: MultiagentDecl;
metadata?: Record<string, string>;
/** Qoder runtime environment variables. Forward delivery stores these as Template defaults. */
environment_variables?: Record<string, string>;
/** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */
delivery?: Record<ProviderName, AgentDeliveryDecl>;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface CommonSessionBindings {
resources?: SessionResource[];
title?: string;
metadata?: Record<string, string>;
/** Provider-neutral representation; Qoder maps it to each Session API's wire shape. */
environment_variables?: Record<string, string>;
}

export interface ManagedSessionBindings extends CommonSessionBindings {
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/tests/unit/map-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ describe("Qoder mapSession", () => {
expect(body.metadata).toEqual({ team: "eng" });
});

test("serializes environment variables using Qoder's managed Session string format", () => {
const body = mapQoderSession({
...minimalBindings(),
environment_variables: { FEATURE_FLAG: "on", LOG_LEVEL: "debug" },
}) as Record<string, unknown>;
expect(body.environment_variables).toBe("FEATURE_FLAG=on;LOG_LEVEL=debug");
});

test("tunnel_id is omitted when not provided", () => {
const bindings = minimalBindings();
const body = mapQoderSession(bindings) as Record<string, unknown>;
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/tests/unit/qoder-forward-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function forwardConfig(): ProjectConfig {
vault: "mcp",
tools: { builtin: ["Bash", "Read"], permissions: { bash: "ask" } },
mcp_servers: [{ name: "coop", type: "http", url: "https://mcp.example.test/mcp" }],
environment_variables: { BASE_MODE: "support" },
delivery: { qoder: { type: "forward" } },
},
},
Expand Down Expand Up @@ -163,6 +164,7 @@ describe("Qoder Forward Template declaration", () => {
describe("Qoder Forward Template mapping and lifecycle", () => {
test("maps BYOC bindings and tool permissions", () => {
const decl = forwardConfig().agents!.assistant!;
decl.environment_variables = { BASE_MODE: "support" };
const body = mapForwardTemplate("assistant", decl, {
environment_id: "env_byoc",
tunnel_id: "tnl_internal",
Expand All @@ -176,6 +178,7 @@ describe("Qoder Forward Template mapping and lifecycle", () => {
tunnel_id: "tnl_internal",
vault_ids: ["vault_mcp"],
mcp_servers: [{ name: "coop", type: "http", url: "https://mcp.example.test/mcp" }],
environment_variables: { BASE_MODE: "support" },
});
expect(body.tools[0].configs).toEqual([
{
Expand Down Expand Up @@ -305,6 +308,7 @@ describe("Qoder Forward Template mapping and lifecycle", () => {
template_id: "tmpl_1",
identity_id: "idn_zhang",
title: "Forward test",
environment_variables: { API_KEY: "secret", REGION: "cn-hangzhou" },
});
const eventId = await adapter.sendSessionMessage(created.id, "hello");
const listed = await adapter.listSessionEvents(created.id, { limit: 100 });
Expand All @@ -316,6 +320,7 @@ describe("Qoder Forward Template mapping and lifecycle", () => {
expect(calls.find((call) => call.path === "/sessions")?.body).toMatchObject({
identity_id: "idn_zhang",
template_id: "tmpl_1",
config: { environment_variables: { API_KEY: "secret", REGION: "cn-hangzhou" } },
});
expect(eventId).toBe("evt_user");
expect(listed.events[0]).toMatchObject({ type: "tool_use", tool_name: "search" });
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/tests/unit/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function makeState(): StateManager {
describe("buildSessionBindings", () => {
test("inherits environment, vault, memory_stores from agent declaration", () => {
const config = makeConfig();
config.agents!.researcher!.environment_variables = { FEATURE_FLAG: "on" };
const state = makeState();
const bindings = buildSessionBindings("researcher", config, "qoder", state);

Expand All @@ -85,6 +86,7 @@ describe("buildSessionBindings", () => {
expect(bindings.environment_id).toBe("env_dev");
expect(bindings.vault_ids).toEqual(["vault_s1"]);
expect(bindings.memory_store_ids).toEqual(["ms_docs"]);
expect(bindings.environment_variables).toEqual({ FEATURE_FLAG: "on" });
});

test("inherits provider-neutral session resources from the agent declaration", () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/sdk/tests/unit/validate-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ test("validates tunnel references and limits tunnels to Qoder", () => {
expect(diagnostics.some((d) => d.code === "claude.agent.tunnel.unsupported")).toBe(true);
});

test("limits Agent environment variables to Qoder", () => {
const config: ProjectConfig = {
version: "1",
providers: { claude: {} },
defaults: { provider: "claude" },
agents: {
assistant: {
model: "claude",
instructions: "test",
environment_variables: { FEATURE_FLAG: "on" },
},
},
};

const diagnostics = validateProjectConfig(config);
expect(diagnostics.some((d) => d.code === "claude.agent.environment_variables.unsupported")).toBe(true);
});

test("rejects tool approval and GitHub Session resources on unsupported providers", () => {
const config: ProjectConfig = {
version: "1",
Expand Down