Skip to content

Commit 9a85adb

Browse files
committed
Update SDK and configuration
Change-Id: Iccd38f76d6e9c122ae867d3825476f58184896d9 Co-developed-by: Qoder <noreply@qoder.com>
1 parent ed905cf commit 9a85adb

13 files changed

Lines changed: 330 additions & 43 deletions

File tree

‎.changeset/pairing-channel.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@openagentpack/sdk": minor
3+
"@openagentpack/cli": minor
4+
"@openagentpack/playground": minor
5+
---
6+
7+
Add `mode: pairing` support for Qoder Channels.
8+
9+
`channels[].mode` now accepts `fixed` (default) or `pairing`. Pairing-mode channels create a transport-only IM connection without binding to an Identity or Template, which is required for Forward Schedule sinks such as scheduled group broadcasts. Fixed-mode channels retain the existing behavior and continue to require `agent` and `identity`.

‎docs/reference/configuration.md‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,10 @@ External references are verified and recorded but never updated or deleted.
7373
channels:
7474
support-dingtalk:
7575
provider: qoder # optional; inherits defaults.provider
76-
agent: support-agent
77-
identity: chen # optional; inherits defaults.identity
76+
agent: support-agent # required for mode: fixed; ignored for mode: pairing
77+
identity: chen # optional; inherits defaults.identity. ignored for mode: pairing
7878
type: dingtalk
79+
mode: fixed # optional; defaults to fixed
7980
name: Support DingTalk # optional; defaults to the YAML key
8081
enabled: true # optional; defaults to true
8182
credentials:
@@ -86,7 +87,34 @@ channels:
8687
include_thinking: false
8788
```
8889

89-
The declaration intentionally uses logical `agent` and `identity` references. Provider adapters resolve remote ids and map `type`, `credentials`, and `options` to provider wire fields. Qoder Channels require the referenced Agent to use Forward delivery. Credential-based Qoder support currently covers DingTalk, Feishu, and WeCom; personal WeChat remains QR-only.
90+
| Field | Type | Required | Description |
91+
| ----- | ---- | -------- | ----------- |
92+
| `provider` | string | no | Provider name; inherits `defaults.provider`. |
93+
| `agent` | string | conditional | Logical Agent name. Required for `fixed` mode; ignored for `pairing` mode. |
94+
| `identity` | string | conditional | Logical Identity name; inherits `defaults.identity`. Required for `fixed` mode; ignored for `pairing` mode. |
95+
| `type` | string | yes | Provider-specific channel type. Qoder supports `dingtalk`, `feishu`, and `wecom`; `wechat` is QR-only. |
96+
| `mode` | `fixed` \| `pairing` | no | `fixed` (default) binds the channel to one Identity/Template. `pairing` creates a transport-only channel for Schedules/Sinks. |
97+
| `name` | string | no | Display name; defaults to the YAML key. |
98+
| `enabled` | boolean | no | Defaults to `true`. |
99+
| `credentials` | map | conditional | Provider-specific credentials. Required for credential-based channel types. |
100+
| `options` | map | no | Provider-specific response options, e.g. `include_tool_calls`, `include_thinking`. |
101+
102+
The declaration intentionally uses logical `agent` and `identity` references. Provider adapters resolve remote ids and map `type`, `credentials`, and `options` to provider wire fields. Qoder Channels in `fixed` mode require the referenced Agent to use Forward delivery. `pairing` mode omits Identity/Template binding and is intended for Schedule sinks such as scheduled group broadcasts. Credential-based Qoder support currently covers DingTalk, Feishu, and WeCom; personal WeChat remains QR-only.
103+
104+
### Managed tool config
105+
106+
`managed_tool_config` declares the provider-operated tools an Agent Harness runs
107+
itself, rather than tools the model calls through the sandbox. Schedule
108+
management is the current use: enabling `create_forward_schedule`,
109+
`list_forward_schedules`, and `delete_forward_schedule` lets an end user create
110+
and cancel Schedules in natural language from a Web or IM Channel conversation.
111+
112+
`enabled_tools` replaces the provider's whole enabled set, so an empty array
113+
turns every managed tool off. Omitting the field entirely sends nothing: because
114+
Qoder Forward Template updates are merge-style, an undeclared field leaves
115+
whatever the remote Template already had. Declare it whenever the tools matter —
116+
a Template recreated from scratch (after a destroy, a manual deletion, or lost
117+
state) otherwise comes back with no managed tools and no error.
90118

91119
## Provider configuration
92120

@@ -253,6 +281,7 @@ agents:
253281
vault: <string>
254282
memory_stores: [ <string> ]
255283
environment_variables: { <key>: <string> } # Qoder only
284+
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
256285
resources: [ SessionResource ]
257286
multiagent: { type: "coordinator", agents: [...] }
258287
metadata: { <key>: <string> }
@@ -274,6 +303,7 @@ agents:
274303
| `vault` | string | no | Vault name. |
275304
| `memory_stores` | string[] | no | Bound memory stores. |
276305
| `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`. |
306+
| `managed_tool_config.enabled_tools` | string[] | no | Provider-operated tools the Agent Harness exposes, e.g. `create_forward_schedule`, `list_forward_schedules`, `delete_forward_schedule`. Qoder Forward delivery only; declaring it on managed delivery is a validation error. |
277307
| `resources` | SessionResource[] | no | Resources attached to every managed Session created for the Agent. |
278308
| `multiagent.type` | `"coordinator"` | no | Declare a coordinator agent. |
279309
| `multiagent.agents` | string[] | yes (with multiagent) | Agents it orchestrates. |

‎packages/sdk/src/internal/core/validate-config.ts‎

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics:
113113
}
114114

115115
for (const [name, channel] of Object.entries(config.channels ?? {})) {
116-
if (!agentNames.has(channel.agent)) {
116+
if (channel.mode === "pairing") continue;
117+
if (!channel.agent) {
118+
diagnostics.error("config.channel.agent.required", `channel.${name}: fixed-mode channels require agent`);
119+
} else if (!agentNames.has(channel.agent)) {
117120
diagnostics.error("config.channel.agent.unknown", `channel.${name}: references unknown agent '${channel.agent}'`);
118121
}
119122
const identity = channel.identity ?? config.defaults?.identity;
@@ -205,29 +208,31 @@ export function collectProviderCapabilities(
205208
}
206209

207210
if (providerName === "qoder") {
208-
const agent = config.agents?.[channel.agent];
209-
if (agent?.provider && agent.provider !== providerName) {
210-
diagnostics.error(
211-
"config.channel.agent.provider_mismatch",
212-
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
213-
{ type: "channel", name, provider: providerName },
214-
);
215-
}
216-
const identityName = channel.identity ?? config.defaults?.identity;
217-
const identity = identityName ? config.identities?.[identityName] : undefined;
218-
if (identity?.provider && identity.provider !== providerName) {
219-
diagnostics.error(
220-
"config.channel.identity.provider_mismatch",
221-
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
222-
{ type: "channel", name, provider: providerName },
223-
);
224-
}
225-
if (agent && agent.delivery?.qoder?.type !== "forward") {
226-
diagnostics.error(
227-
"qoder.channel.forward_template.required",
228-
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
229-
{ type: "channel", name, provider: providerName },
230-
);
211+
if (channel.mode !== "pairing" && channel.agent) {
212+
const agent = config.agents?.[channel.agent];
213+
if (agent?.provider && agent.provider !== providerName) {
214+
diagnostics.error(
215+
"config.channel.agent.provider_mismatch",
216+
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
217+
{ type: "channel", name, provider: providerName },
218+
);
219+
}
220+
const identityName = channel.identity ?? config.defaults?.identity;
221+
const identity = identityName ? config.identities?.[identityName] : undefined;
222+
if (identity?.provider && identity.provider !== providerName) {
223+
diagnostics.error(
224+
"config.channel.identity.provider_mismatch",
225+
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
226+
{ type: "channel", name, provider: providerName },
227+
);
228+
}
229+
if (agent && agent.delivery?.qoder?.type !== "forward") {
230+
diagnostics.error(
231+
"qoder.channel.forward_template.required",
232+
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
233+
{ type: "channel", name, provider: providerName },
234+
);
235+
}
231236
}
232237
const requiredCredentials: Record<string, string[]> = {
233238
dingtalk: ["client_id", "client_secret"],
@@ -306,6 +311,13 @@ export function collectProviderCapabilities(
306311
address,
307312
);
308313
}
314+
if (delivery !== "forward" && agent.managed_tool_config) {
315+
diagnostics.error(
316+
`${providerName}.agent.managed_tool_config.forward_required`,
317+
`agent.${name}: managed_tool_config applies to Forward Templates; set delivery.${providerName}.type: forward or remove it.`,
318+
address,
319+
);
320+
}
309321
if (delivery === "forward" && !isSupported(caps, "template")) {
310322
diagnostics.error(
311323
`${providerName}.agent.delivery.forward.unsupported`,
@@ -386,6 +398,13 @@ export function collectProviderCapabilities(
386398
{ type: "agent", name, provider: providerName },
387399
);
388400
}
401+
if (agent.managed_tool_config && (!agent.provider || agent.provider === providerName)) {
402+
diagnostics.error(
403+
`${providerName}.agent.managed_tool_config.unsupported`,
404+
`agent.${name}: managed_tool_config is supported only by Qoder; remove it or pin this agent to qoder.`,
405+
{ type: "agent", name, provider: providerName },
406+
);
407+
}
389408
if (agent.tunnel && (!agent.provider || agent.provider === providerName)) {
390409
diagnostics.error(
391410
`${providerName}.agent.tunnel.unsupported`,

‎packages/sdk/src/internal/executor/resolver.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ export function resolveChannelRefs(
179179
const channel = config.channels?.[channelName];
180180
if (!channel) throw new UserError(`Channel '${channelName}' not found in config`);
181181

182+
if (channel.mode === "pairing") {
183+
return {};
184+
}
185+
186+
if (!channel.agent) {
187+
throw new UserError(`Channel '${channelName}' is fixed mode and must declare agent`);
188+
}
182189
const agent = config.agents?.[channel.agent];
183190
if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`);
184191
const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent";

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str
190190
const channelAddr: ResourceAddress = { type: "channel", name, provider };
191191
addNode(channelAddr);
192192

193+
if (decl.mode === "pairing" || !decl.agent) continue;
194+
193195
const agentDecl = config.agents?.[decl.agent];
194196
const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent";
195197
const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider };

‎packages/sdk/src/internal/parser/schema.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,10 @@ const agentDeliverySchema = z.object({
231231
type: z.enum(["managed", "forward"]),
232232
});
233233

234+
const managedToolConfigSchema = z.object({
235+
enabled_tools: z.array(z.string().min(1)),
236+
});
237+
234238
const sessionGithubRepoResourceSchema = z.object({
235239
type: z.literal("github_repository"),
236240
url: z.string().url(),
@@ -261,15 +265,17 @@ const agentSchema = z.object({
261265
multiagent: multiagentSchema.optional(),
262266
metadata: z.record(z.string(), z.string()).optional(),
263267
environment_variables: z.record(z.string().min(1), z.string()).optional(),
268+
managed_tool_config: managedToolConfigSchema.optional(),
264269
delivery: z.record(z.string(), agentDeliverySchema).optional(),
265270
});
266271

267272
const channelSchema = z.object({
268273
provider: z.string().optional(),
269-
agent: z.string().min(1),
274+
agent: z.string().min(1).optional(),
270275
identity: z.string().min(1).optional(),
271276
type: z.string().min(1),
272277
name: z.string().trim().min(1).optional(),
278+
mode: z.enum(["fixed", "pairing"]).optional().default("fixed"),
273279
enabled: z.boolean().optional(),
274280
credentials: z.record(z.string(), coerceString).optional(),
275281
options: z.record(z.string(), z.unknown()).optional(),

‎packages/sdk/src/internal/planner/hasher.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,18 @@ export function computeReplacementFingerprint(address: ResourceAddress, config:
5656
if (address.type !== "channel") return undefined;
5757
const decl = config.channels?.[address.name];
5858
if (!decl) return undefined;
59-
return contentHash({ channel_type: decl.type, credentials: decl.credentials ?? {} });
59+
return contentHash({ channel_type: decl.type, mode: decl.mode ?? "fixed", credentials: decl.credentials ?? {} });
6060
}
6161

6262
function resolveChannelReferenceIds(
63-
decl: { agent: string; identity?: string },
63+
decl: { agent?: string; identity?: string; mode?: "fixed" | "pairing" },
6464
config: ProjectConfig,
6565
provider: string,
6666
state?: HashStateLookup,
6767
): Record<string, string | null | undefined> {
68+
if (decl.mode === "pairing" || !decl.agent) {
69+
return { mode: "pairing" };
70+
}
6871
const agent = config.agents?.[decl.agent];
6972
const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent";
7073
const identity = decl.identity ?? config.defaults?.identity;

‎packages/sdk/src/internal/providers/interface.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ export interface ResolvedDeploymentRefs {
9797
}
9898

9999
export interface ResolvedChannelRefs {
100-
identity_id: string;
101-
agent_id: string;
100+
identity_id?: string;
101+
agent_id?: string;
102102
}
103103

104104
export interface DeploymentContext {

‎packages/sdk/src/internal/providers/qoder/adapter.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -353,16 +353,21 @@ export class QoderAdapter implements ProviderAdapter {
353353
}
354354
if (type === "channel") {
355355
const channelConfig = (raw.channel_config ?? {}) as Record<string, unknown>;
356-
return compactDeep({
357-
identity_id: raw.identity_id,
358-
template_id: raw.template_id,
356+
const mode = (raw.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
357+
const normalized: Record<string, unknown> = {
358+
identity_resolution: { mode },
359359
channel_type: raw.channel_type,
360360
name: raw.name,
361361
enabled: raw.enabled,
362362
channel_config: {
363363
response_options: channelConfig.response_options ?? {},
364364
},
365-
});
365+
};
366+
if (mode === "fixed") {
367+
normalized.identity_id = raw.identity_id;
368+
normalized.template_id = raw.template_id;
369+
}
370+
return compactDeep(normalized);
366371
}
367372

368373
return compactDeep({
@@ -563,12 +568,14 @@ export class QoderAdapter implements ProviderAdapter {
563568

564569
async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise<RemoteResource> {
565570
const current = (await this.forwardClient.get(`/channels/${id}`)) as Record<string, unknown>;
566-
if (current.channel_type !== decl.type) {
571+
const currentMode = (current.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
572+
if (current.channel_type !== decl.type || currentMode !== (decl.mode ?? "fixed")) {
567573
await this.deleteChannel(id);
568574
return this.createChannel(name, decl, refs);
569575
}
570576
const body = this.mapChannel(name, decl, refs);
571577
delete body.channel_type;
578+
delete body.identity_resolution;
572579
const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record<string, unknown>;
573580
return toRemoteResource(res);
574581
}
@@ -578,9 +585,8 @@ export class QoderAdapter implements ProviderAdapter {
578585
}
579586

580587
private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record<string, unknown> {
581-
return {
582-
identity_id: refs.identity_id,
583-
template_id: refs.agent_id,
588+
const mode = decl.mode ?? "fixed";
589+
const body: Record<string, unknown> = {
584590
channel_type: decl.type,
585591
name: decl.name ?? name,
586592
enabled: decl.enabled ?? true,
@@ -593,6 +599,13 @@ export class QoderAdapter implements ProviderAdapter {
593599
},
594600
},
595601
};
602+
if (mode === "pairing") {
603+
body.identity_resolution = { mode: "pairing" };
604+
} else {
605+
body.identity_id = refs.identity_id;
606+
body.template_id = refs.agent_id;
607+
}
608+
return body;
596609
}
597610

598611
private async registerForwardVaults(vaultIds: string[]): Promise<void> {

‎packages/sdk/src/internal/providers/qoder/mapper.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,9 @@ export function mapForwardTemplate(
493493
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
494494
else body.metadata = decl.metadata ?? {};
495495
if (decl.environment_variables) body.environment_variables = decl.environment_variables;
496+
// Sent on create and on update: Forward updates are merge-style, so omitting
497+
// the field would silently keep whatever the Template already had.
498+
if (decl.managed_tool_config) body.managed_tool_config = decl.managed_tool_config;
496499

497500
if (decl.tools) {
498501
body.tools = [

0 commit comments

Comments
 (0)