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
9 changes: 9 additions & 0 deletions .changeset/pairing-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@openagentpack/sdk": minor
"@openagentpack/cli": minor
"@openagentpack/playground": minor
---

Add `mode: pairing` support for Qoder Channels.

`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`.
36 changes: 33 additions & 3 deletions docs/reference/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,9 +73,10 @@ External references are verified and recorded but never updated or deleted.
channels:
support-dingtalk:
provider: qoder # optional; inherits defaults.provider
agent: support-agent
identity: chen # optional; inherits defaults.identity
agent: support-agent # required for mode: fixed; ignored for mode: pairing
identity: chen # optional; inherits defaults.identity. ignored for mode: pairing
type: dingtalk
mode: fixed # optional; defaults to fixed
name: Support DingTalk # optional; defaults to the YAML key
enabled: true # optional; defaults to true
credentials:
Expand All@@ -86,7 +87,34 @@ channels:
include_thinking: false
```

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.
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `provider` | string | no | Provider name; inherits `defaults.provider`. |
| `agent` | string | conditional | Logical Agent name. Required for `fixed` mode; ignored for `pairing` mode. |
| `identity` | string | conditional | Logical Identity name; inherits `defaults.identity`. Required for `fixed` mode; ignored for `pairing` mode. |
| `type` | string | yes | Provider-specific channel type. Qoder supports `dingtalk`, `feishu`, and `wecom`; `wechat` is QR-only. |
| `mode` | `fixed` \| `pairing` | no | `fixed` (default) binds the channel to one Identity/Template. `pairing` creates a transport-only channel for Schedules/Sinks. |
| `name` | string | no | Display name; defaults to the YAML key. |
| `enabled` | boolean | no | Defaults to `true`. |
| `credentials` | map | conditional | Provider-specific credentials. Required for credential-based channel types. |
| `options` | map | no | Provider-specific response options, e.g. `include_tool_calls`, `include_thinking`. |

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.

### Managed tool config

`managed_tool_config` declares the provider-operated tools an Agent Harness runs
itself, rather than tools the model calls through the sandbox. Schedule
management is the current use: enabling `create_forward_schedule`,
`list_forward_schedules`, and `delete_forward_schedule` lets an end user create
and cancel Schedules in natural language from a Web or IM Channel conversation.

`enabled_tools` replaces the provider's whole enabled set, so an empty array
turns every managed tool off. Omitting the field entirely sends nothing: because
Qoder Forward Template updates are merge-style, an undeclared field leaves
whatever the remote Template already had. Declare it whenever the tools matter —
a Template recreated from scratch (after a destroy, a manual deletion, or lost
state) otherwise comes back with no managed tools and no error.

## Provider configuration

Expand DownExpand Up@@ -253,6 +281,7 @@ agents:
vault: <string>
memory_stores: [ <string> ]
environment_variables: { <key>: <string> } # Qoder only
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
Expand All@@ -274,6 +303,7 @@ agents:
| `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`. |
| `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. |
| `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
67 changes: 43 additions & 24 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,10 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics:
}

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

if (providerName === "qoder") {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
if (channel.mode !== "pairing" && channel.agent) {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
}
}
const requiredCredentials: Record<string, string[]> = {
dingtalk: ["client_id", "client_secret"],
Expand DownExpand Up@@ -306,6 +311,13 @@ export function collectProviderCapabilities(
address,
);
}
if (delivery !== "forward" && agent.managed_tool_config) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.forward_required`,
`agent.${name}: managed_tool_config applies to Forward Templates; set delivery.${providerName}.type: forward or remove it.`,
address,
);
}
if (delivery === "forward" && !isSupported(caps, "template")) {
diagnostics.error(
`${providerName}.agent.delivery.forward.unsupported`,
Expand DownExpand Up@@ -463,6 +475,13 @@ export function collectProviderCapabilities(
{ type: "agent", name, provider: providerName },
);
}
if (agent.managed_tool_config && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.unsupported`,
`agent.${name}: managed_tool_config 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
7 changes: 7 additions & 0 deletions packages/sdk/src/internal/executor/resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -179,6 +179,13 @@ export function resolveChannelRefs(
const channel = config.channels?.[channelName];
if (!channel) throw new UserError(`Channel '${channelName}' not found in config`);

if (channel.mode === "pairing") {
return {};
}

if (!channel.agent) {
throw new UserError(`Channel '${channelName}' is fixed mode and must declare agent`);
}
const agent = config.agents?.[channel.agent];
if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`);
const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent";
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/graph/dependency.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,6 +190,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str
const channelAddr: ResourceAddress = { type: "channel", name, provider };
addNode(channelAddr);

if (decl.mode === "pairing" || !decl.agent) continue;

const agentDecl = config.agents?.[decl.agent];
const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent";
const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider };
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/internal/parser/schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,6 +231,10 @@ const agentDeliverySchema = z.object({
type: z.enum(["managed", "forward"]),
});

const managedToolConfigSchema = z.object({
enabled_tools: z.array(z.string().min(1)),
});

const sessionGithubRepoResourceSchema = z.object({
type: z.literal("github_repository"),
url: z.string().url(),
Expand DownExpand Up@@ -261,15 +265,17 @@ const agentSchema = z.object({
multiagent: multiagentSchema.optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.record(z.string().min(1), z.string()).optional(),
managed_tool_config: managedToolConfigSchema.optional(),
delivery: z.record(z.string(), agentDeliverySchema).optional(),
});

const channelSchema = z.object({
provider: z.string().optional(),
agent: z.string().min(1),
agent: z.string().min(1).optional(),
identity: z.string().min(1).optional(),
type: z.string().min(1),
name: z.string().trim().min(1).optional(),
mode: z.enum(["fixed", "pairing"]).optional().default("fixed"),
enabled: z.boolean().optional(),
credentials: z.record(z.string(), coerceString).optional(),
options: z.record(z.string(), z.unknown()).optional(),
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/internal/planner/hasher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,15 +63,18 @@ export function computeReplacementFingerprint(address: ResourceAddress, config:
if (address.type !== "channel") return undefined;
const decl = config.channels?.[address.name];
if (!decl) return undefined;
return contentHash({ channel_type: decl.type, credentials: decl.credentials ?? {} });
return contentHash({ channel_type: decl.type, mode: decl.mode ?? "fixed", credentials: decl.credentials ?? {} });
}

function resolveChannelReferenceIds(
decl: { agent: string; identity?: string },
decl: { agent?: string; identity?: string; mode?: "fixed" | "pairing" },
config: ProjectConfig,
provider: string,
state?: HashStateLookup,
): Record<string, string | null | undefined> {
if (decl.mode === "pairing" || !decl.agent) {
return { mode: "pairing" };
}
const agent = config.agents?.[decl.agent];
const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent";
const identity = decl.identity ?? config.defaults?.identity;
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/internal/providers/interface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,8 +97,8 @@ export interface ResolvedDeploymentRefs {
}

export interface ResolvedChannelRefs {
identity_id: string;
agent_id: string;
identity_id?: string;
agent_id?: string;
}

export interface DeploymentContext {
Expand Down
29 changes: 21 additions & 8 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,16 +354,21 @@ export class QoderAdapter implements ProviderAdapter {
}
if (type === "channel") {
const channelConfig = (raw.channel_config ?? {}) as Record<string, unknown>;
return compactDeep({
identity_id: raw.identity_id,
template_id: raw.template_id,
const mode = (raw.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
const normalized: Record<string, unknown> = {
identity_resolution: { mode },
channel_type: raw.channel_type,
name: raw.name,
enabled: raw.enabled,
channel_config: {
response_options: channelConfig.response_options ?? {},
},
});
};
if (mode === "fixed") {
normalized.identity_id = raw.identity_id;
normalized.template_id = raw.template_id;
}
return compactDeep(normalized);
}

return compactDeep({
Expand DownExpand Up@@ -564,12 +569,14 @@ export class QoderAdapter implements ProviderAdapter {

async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise<RemoteResource> {
const current = (await this.forwardClient.get(`/channels/${id}`)) as Record<string, unknown>;
if (current.channel_type !== decl.type) {
const currentMode = (current.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
if (current.channel_type !== decl.type || currentMode !== (decl.mode ?? "fixed")) {
await this.deleteChannel(id);
return this.createChannel(name, decl, refs);
}
const body = this.mapChannel(name, decl, refs);
delete body.channel_type;
delete body.identity_resolution;
const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record<string, unknown>;
return toRemoteResource(res);
}
Expand All@@ -579,9 +586,8 @@ export class QoderAdapter implements ProviderAdapter {
}

private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record<string, unknown> {
return {
identity_id: refs.identity_id,
template_id: refs.agent_id,
const mode = decl.mode ?? "fixed";
const body: Record<string, unknown> = {
channel_type: decl.type,
name: decl.name ?? name,
enabled: decl.enabled ?? true,
Expand All@@ -594,6 +600,13 @@ export class QoderAdapter implements ProviderAdapter {
},
},
};
if (mode === "pairing") {
body.identity_resolution = { mode: "pairing" };
} else {
body.identity_id = refs.identity_id;
body.template_id = refs.agent_id;
}
return body;
}

private async registerForwardVaults(vaultIds: string[]): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/internal/providers/qoder/mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -493,6 +493,9 @@ export function mapForwardTemplate(
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
else body.metadata = decl.metadata ?? {};
if (decl.environment_variables) body.environment_variables = decl.environment_variables;
// Sent on create and on update: Forward updates are merge-style, so omitting
// the field would silently keep whatever the Template already had.
if (decl.managed_tool_config) body.managed_tool_config = decl.managed_tool_config;

if (decl.tools) {
body.tools = [
Expand Down
15 changes: 12 additions & 3 deletions packages/sdk/src/internal/types/config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -177,10 +177,17 @@ export interface AgentDecl {
metadata?: Record<string, string>;
/** Qoder runtime environment variables. Forward delivery stores these as Template defaults. */
environment_variables?: Record<string, string>;
/** Provider-side tools the Agent Harness operates itself. Qoder Forward delivery only. */
managed_tool_config?: ManagedToolConfigDecl;
/** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */
delivery?: Record<ProviderName, AgentDeliveryDecl>;
}

export interface ManagedToolConfigDecl {
/** Replaces the provider's enabled managed-tool set; an empty array disables all of them. */
enabled_tools: string[];
}

export interface AgentDeliveryDecl {
type: "managed" | "forward";
}
Expand All@@ -189,12 +196,14 @@ export interface AgentDeliveryDecl {

export interface ChannelDecl {
provider?: ProviderName;
/** Logical Agent name. The provider adapter resolves its materialized remote resource. */
agent: string;
/** Logical Identity name. Falls back to defaults.identity. */
/** Logical Agent name. The provider adapter resolves its materialized remote resource. Required for `fixed` mode; ignored for `pairing` mode. */
agent?: string;
/** Logical Identity name. Falls back to defaults.identity. Required for `fixed` mode; ignored for `pairing` mode. */
identity?: string;
type: string;
name?: string;
/** Identity resolution mode. `fixed` binds the channel to one Identity/Template; `pairing` creates a transport-only channel used by Schedules/Sinks. Defaults to `fixed`. */
mode?: "fixed" | "pairing";
enabled?: boolean;
credentials?: Record<string, string>;
options?: Record<string, unknown>;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Update SDK and configuration by heimanba · Pull Request #88 · modelstudioai/OpenAgentPack · GitHub
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
9 changes: 9 additions & 0 deletions .changeset/pairing-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@openagentpack/sdk": minor
"@openagentpack/cli": minor
"@openagentpack/playground": minor
---

Add `mode: pairing` support for Qoder Channels.

`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`.
36 changes: 33 additions & 3 deletions docs/reference/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,9 +73,10 @@ External references are verified and recorded but never updated or deleted.
channels:
support-dingtalk:
provider: qoder # optional; inherits defaults.provider
agent: support-agent
identity: chen # optional; inherits defaults.identity
agent: support-agent # required for mode: fixed; ignored for mode: pairing
identity: chen # optional; inherits defaults.identity. ignored for mode: pairing
type: dingtalk
mode: fixed # optional; defaults to fixed
name: Support DingTalk # optional; defaults to the YAML key
enabled: true # optional; defaults to true
credentials:
Expand All@@ -86,7 +87,34 @@ channels:
include_thinking: false
```

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.
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `provider` | string | no | Provider name; inherits `defaults.provider`. |
| `agent` | string | conditional | Logical Agent name. Required for `fixed` mode; ignored for `pairing` mode. |
| `identity` | string | conditional | Logical Identity name; inherits `defaults.identity`. Required for `fixed` mode; ignored for `pairing` mode. |
| `type` | string | yes | Provider-specific channel type. Qoder supports `dingtalk`, `feishu`, and `wecom`; `wechat` is QR-only. |
| `mode` | `fixed` \| `pairing` | no | `fixed` (default) binds the channel to one Identity/Template. `pairing` creates a transport-only channel for Schedules/Sinks. |
| `name` | string | no | Display name; defaults to the YAML key. |
| `enabled` | boolean | no | Defaults to `true`. |
| `credentials` | map | conditional | Provider-specific credentials. Required for credential-based channel types. |
| `options` | map | no | Provider-specific response options, e.g. `include_tool_calls`, `include_thinking`. |

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.

### Managed tool config

`managed_tool_config` declares the provider-operated tools an Agent Harness runs
itself, rather than tools the model calls through the sandbox. Schedule
management is the current use: enabling `create_forward_schedule`,
`list_forward_schedules`, and `delete_forward_schedule` lets an end user create
and cancel Schedules in natural language from a Web or IM Channel conversation.

`enabled_tools` replaces the provider's whole enabled set, so an empty array
turns every managed tool off. Omitting the field entirely sends nothing: because
Qoder Forward Template updates are merge-style, an undeclared field leaves
whatever the remote Template already had. Declare it whenever the tools matter —
a Template recreated from scratch (after a destroy, a manual deletion, or lost
state) otherwise comes back with no managed tools and no error.

## Provider configuration

Expand DownExpand Up@@ -253,6 +281,7 @@ agents:
vault: <string>
memory_stores: [ <string> ]
environment_variables: { <key>: <string> } # Qoder only
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
Expand All@@ -274,6 +303,7 @@ agents:
| `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`. |
| `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. |
| `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
67 changes: 43 additions & 24 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,10 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics:
}

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

if (providerName === "qoder") {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
if (channel.mode !== "pairing" && channel.agent) {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
}
}
const requiredCredentials: Record<string, string[]> = {
dingtalk: ["client_id", "client_secret"],
Expand DownExpand Up@@ -306,6 +311,13 @@ export function collectProviderCapabilities(
address,
);
}
if (delivery !== "forward" && agent.managed_tool_config) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.forward_required`,
`agent.${name}: managed_tool_config applies to Forward Templates; set delivery.${providerName}.type: forward or remove it.`,
address,
);
}
if (delivery === "forward" && !isSupported(caps, "template")) {
diagnostics.error(
`${providerName}.agent.delivery.forward.unsupported`,
Expand DownExpand Up@@ -463,6 +475,13 @@ export function collectProviderCapabilities(
{ type: "agent", name, provider: providerName },
);
}
if (agent.managed_tool_config && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.unsupported`,
`agent.${name}: managed_tool_config 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
7 changes: 7 additions & 0 deletions packages/sdk/src/internal/executor/resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -179,6 +179,13 @@ export function resolveChannelRefs(
const channel = config.channels?.[channelName];
if (!channel) throw new UserError(`Channel '${channelName}' not found in config`);

if (channel.mode === "pairing") {
return {};
}

if (!channel.agent) {
throw new UserError(`Channel '${channelName}' is fixed mode and must declare agent`);
}
const agent = config.agents?.[channel.agent];
if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`);
const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent";
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/graph/dependency.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,6 +190,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str
const channelAddr: ResourceAddress = { type: "channel", name, provider };
addNode(channelAddr);

if (decl.mode === "pairing" || !decl.agent) continue;

const agentDecl = config.agents?.[decl.agent];
const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent";
const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider };
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/internal/parser/schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,6 +231,10 @@ const agentDeliverySchema = z.object({
type: z.enum(["managed", "forward"]),
});

const managedToolConfigSchema = z.object({
enabled_tools: z.array(z.string().min(1)),
});

const sessionGithubRepoResourceSchema = z.object({
type: z.literal("github_repository"),
url: z.string().url(),
Expand DownExpand Up@@ -261,15 +265,17 @@ const agentSchema = z.object({
multiagent: multiagentSchema.optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.record(z.string().min(1), z.string()).optional(),
managed_tool_config: managedToolConfigSchema.optional(),
delivery: z.record(z.string(), agentDeliverySchema).optional(),
});

const channelSchema = z.object({
provider: z.string().optional(),
agent: z.string().min(1),
agent: z.string().min(1).optional(),
identity: z.string().min(1).optional(),
type: z.string().min(1),
name: z.string().trim().min(1).optional(),
mode: z.enum(["fixed", "pairing"]).optional().default("fixed"),
enabled: z.boolean().optional(),
credentials: z.record(z.string(), coerceString).optional(),
options: z.record(z.string(), z.unknown()).optional(),
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/internal/planner/hasher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,15 +63,18 @@ export function computeReplacementFingerprint(address: ResourceAddress, config:
if (address.type !== "channel") return undefined;
const decl = config.channels?.[address.name];
if (!decl) return undefined;
return contentHash({ channel_type: decl.type, credentials: decl.credentials ?? {} });
return contentHash({ channel_type: decl.type, mode: decl.mode ?? "fixed", credentials: decl.credentials ?? {} });
}

function resolveChannelReferenceIds(
decl: { agent: string; identity?: string },
decl: { agent?: string; identity?: string; mode?: "fixed" | "pairing" },
config: ProjectConfig,
provider: string,
state?: HashStateLookup,
): Record<string, string | null | undefined> {
if (decl.mode === "pairing" || !decl.agent) {
return { mode: "pairing" };
}
const agent = config.agents?.[decl.agent];
const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent";
const identity = decl.identity ?? config.defaults?.identity;
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/internal/providers/interface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,8 +97,8 @@ export interface ResolvedDeploymentRefs {
}

export interface ResolvedChannelRefs {
identity_id: string;
agent_id: string;
identity_id?: string;
agent_id?: string;
}

export interface DeploymentContext {
Expand Down
29 changes: 21 additions & 8 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,16 +354,21 @@ export class QoderAdapter implements ProviderAdapter {
}
if (type === "channel") {
const channelConfig = (raw.channel_config ?? {}) as Record<string, unknown>;
return compactDeep({
identity_id: raw.identity_id,
template_id: raw.template_id,
const mode = (raw.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
const normalized: Record<string, unknown> = {
identity_resolution: { mode },
channel_type: raw.channel_type,
name: raw.name,
enabled: raw.enabled,
channel_config: {
response_options: channelConfig.response_options ?? {},
},
});
};
if (mode === "fixed") {
normalized.identity_id = raw.identity_id;
normalized.template_id = raw.template_id;
}
return compactDeep(normalized);
}

return compactDeep({
Expand DownExpand Up@@ -564,12 +569,14 @@ export class QoderAdapter implements ProviderAdapter {

async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise<RemoteResource> {
const current = (await this.forwardClient.get(`/channels/${id}`)) as Record<string, unknown>;
if (current.channel_type !== decl.type) {
const currentMode = (current.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
if (current.channel_type !== decl.type || currentMode !== (decl.mode ?? "fixed")) {
await this.deleteChannel(id);
return this.createChannel(name, decl, refs);
}
const body = this.mapChannel(name, decl, refs);
delete body.channel_type;
delete body.identity_resolution;
const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record<string, unknown>;
return toRemoteResource(res);
}
Expand All@@ -579,9 +586,8 @@ export class QoderAdapter implements ProviderAdapter {
}

private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record<string, unknown> {
return {
identity_id: refs.identity_id,
template_id: refs.agent_id,
const mode = decl.mode ?? "fixed";
const body: Record<string, unknown> = {
channel_type: decl.type,
name: decl.name ?? name,
enabled: decl.enabled ?? true,
Expand All@@ -594,6 +600,13 @@ export class QoderAdapter implements ProviderAdapter {
},
},
};
if (mode === "pairing") {
body.identity_resolution = { mode: "pairing" };
} else {
body.identity_id = refs.identity_id;
body.template_id = refs.agent_id;
}
return body;
}

private async registerForwardVaults(vaultIds: string[]): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/internal/providers/qoder/mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -493,6 +493,9 @@ export function mapForwardTemplate(
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
else body.metadata = decl.metadata ?? {};
if (decl.environment_variables) body.environment_variables = decl.environment_variables;
// Sent on create and on update: Forward updates are merge-style, so omitting
// the field would silently keep whatever the Template already had.
if (decl.managed_tool_config) body.managed_tool_config = decl.managed_tool_config;

if (decl.tools) {
body.tools = [
Expand Down
15 changes: 12 additions & 3 deletions packages/sdk/src/internal/types/config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -177,10 +177,17 @@ export interface AgentDecl {
metadata?: Record<string, string>;
/** Qoder runtime environment variables. Forward delivery stores these as Template defaults. */
environment_variables?: Record<string, string>;
/** Provider-side tools the Agent Harness operates itself. Qoder Forward delivery only. */
managed_tool_config?: ManagedToolConfigDecl;
/** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */
delivery?: Record<ProviderName, AgentDeliveryDecl>;
}

export interface ManagedToolConfigDecl {
/** Replaces the provider's enabled managed-tool set; an empty array disables all of them. */
enabled_tools: string[];
}

export interface AgentDeliveryDecl {
type: "managed" | "forward";
}
Expand All@@ -189,12 +196,14 @@ export interface AgentDeliveryDecl {

export interface ChannelDecl {
provider?: ProviderName;
/** Logical Agent name. The provider adapter resolves its materialized remote resource. */
agent: string;
/** Logical Identity name. Falls back to defaults.identity. */
/** Logical Agent name. The provider adapter resolves its materialized remote resource. Required for `fixed` mode; ignored for `pairing` mode. */
agent?: string;
/** Logical Identity name. Falls back to defaults.identity. Required for `fixed` mode; ignored for `pairing` mode. */
identity?: string;
type: string;
name?: string;
/** Identity resolution mode. `fixed` binds the channel to one Identity/Template; `pairing` creates a transport-only channel used by Schedules/Sinks. Defaults to `fixed`. */
mode?: "fixed" | "pairing";
enabled?: boolean;
credentials?: Record<string, string>;
options?: Record<string, unknown>;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Update SDK and configuration by heimanba · Pull Request #88 · modelstudioai/OpenAgentPack · GitHub
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
9 changes: 9 additions & 0 deletions .changeset/pairing-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@openagentpack/sdk": minor
"@openagentpack/cli": minor
"@openagentpack/playground": minor
---

Add `mode: pairing` support for Qoder Channels.

`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`.
36 changes: 33 additions & 3 deletions docs/reference/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,9 +73,10 @@ External references are verified and recorded but never updated or deleted.
channels:
support-dingtalk:
provider: qoder # optional; inherits defaults.provider
agent: support-agent
identity: chen # optional; inherits defaults.identity
agent: support-agent # required for mode: fixed; ignored for mode: pairing
identity: chen # optional; inherits defaults.identity. ignored for mode: pairing
type: dingtalk
mode: fixed # optional; defaults to fixed
name: Support DingTalk # optional; defaults to the YAML key
enabled: true # optional; defaults to true
credentials:
Expand All@@ -86,7 +87,34 @@ channels:
include_thinking: false
```

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.
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `provider` | string | no | Provider name; inherits `defaults.provider`. |
| `agent` | string | conditional | Logical Agent name. Required for `fixed` mode; ignored for `pairing` mode. |
| `identity` | string | conditional | Logical Identity name; inherits `defaults.identity`. Required for `fixed` mode; ignored for `pairing` mode. |
| `type` | string | yes | Provider-specific channel type. Qoder supports `dingtalk`, `feishu`, and `wecom`; `wechat` is QR-only. |
| `mode` | `fixed` \| `pairing` | no | `fixed` (default) binds the channel to one Identity/Template. `pairing` creates a transport-only channel for Schedules/Sinks. |
| `name` | string | no | Display name; defaults to the YAML key. |
| `enabled` | boolean | no | Defaults to `true`. |
| `credentials` | map | conditional | Provider-specific credentials. Required for credential-based channel types. |
| `options` | map | no | Provider-specific response options, e.g. `include_tool_calls`, `include_thinking`. |

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.

### Managed tool config

`managed_tool_config` declares the provider-operated tools an Agent Harness runs
itself, rather than tools the model calls through the sandbox. Schedule
management is the current use: enabling `create_forward_schedule`,
`list_forward_schedules`, and `delete_forward_schedule` lets an end user create
and cancel Schedules in natural language from a Web or IM Channel conversation.

`enabled_tools` replaces the provider's whole enabled set, so an empty array
turns every managed tool off. Omitting the field entirely sends nothing: because
Qoder Forward Template updates are merge-style, an undeclared field leaves
whatever the remote Template already had. Declare it whenever the tools matter —
a Template recreated from scratch (after a destroy, a manual deletion, or lost
state) otherwise comes back with no managed tools and no error.

## Provider configuration

Expand DownExpand Up@@ -253,6 +281,7 @@ agents:
vault: <string>
memory_stores: [ <string> ]
environment_variables: { <key>: <string> } # Qoder only
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
Expand All@@ -274,6 +303,7 @@ agents:
| `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`. |
| `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. |
| `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
67 changes: 43 additions & 24 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,10 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics:
}

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

if (providerName === "qoder") {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
if (channel.mode !== "pairing" && channel.agent) {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
}
}
const requiredCredentials: Record<string, string[]> = {
dingtalk: ["client_id", "client_secret"],
Expand DownExpand Up@@ -306,6 +311,13 @@ export function collectProviderCapabilities(
address,
);
}
if (delivery !== "forward" && agent.managed_tool_config) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.forward_required`,
`agent.${name}: managed_tool_config applies to Forward Templates; set delivery.${providerName}.type: forward or remove it.`,
address,
);
}
if (delivery === "forward" && !isSupported(caps, "template")) {
diagnostics.error(
`${providerName}.agent.delivery.forward.unsupported`,
Expand DownExpand Up@@ -463,6 +475,13 @@ export function collectProviderCapabilities(
{ type: "agent", name, provider: providerName },
);
}
if (agent.managed_tool_config && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.unsupported`,
`agent.${name}: managed_tool_config 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
7 changes: 7 additions & 0 deletions packages/sdk/src/internal/executor/resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -179,6 +179,13 @@ export function resolveChannelRefs(
const channel = config.channels?.[channelName];
if (!channel) throw new UserError(`Channel '${channelName}' not found in config`);

if (channel.mode === "pairing") {
return {};
}

if (!channel.agent) {
throw new UserError(`Channel '${channelName}' is fixed mode and must declare agent`);
}
const agent = config.agents?.[channel.agent];
if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`);
const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent";
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/graph/dependency.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,6 +190,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str
const channelAddr: ResourceAddress = { type: "channel", name, provider };
addNode(channelAddr);

if (decl.mode === "pairing" || !decl.agent) continue;

const agentDecl = config.agents?.[decl.agent];
const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent";
const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider };
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/internal/parser/schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,6 +231,10 @@ const agentDeliverySchema = z.object({
type: z.enum(["managed", "forward"]),
});

const managedToolConfigSchema = z.object({
enabled_tools: z.array(z.string().min(1)),
});

const sessionGithubRepoResourceSchema = z.object({
type: z.literal("github_repository"),
url: z.string().url(),
Expand DownExpand Up@@ -261,15 +265,17 @@ const agentSchema = z.object({
multiagent: multiagentSchema.optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.record(z.string().min(1), z.string()).optional(),
managed_tool_config: managedToolConfigSchema.optional(),
delivery: z.record(z.string(), agentDeliverySchema).optional(),
});

const channelSchema = z.object({
provider: z.string().optional(),
agent: z.string().min(1),
agent: z.string().min(1).optional(),
identity: z.string().min(1).optional(),
type: z.string().min(1),
name: z.string().trim().min(1).optional(),
mode: z.enum(["fixed", "pairing"]).optional().default("fixed"),
enabled: z.boolean().optional(),
credentials: z.record(z.string(), coerceString).optional(),
options: z.record(z.string(), z.unknown()).optional(),
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/internal/planner/hasher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,15 +63,18 @@ export function computeReplacementFingerprint(address: ResourceAddress, config:
if (address.type !== "channel") return undefined;
const decl = config.channels?.[address.name];
if (!decl) return undefined;
return contentHash({ channel_type: decl.type, credentials: decl.credentials ?? {} });
return contentHash({ channel_type: decl.type, mode: decl.mode ?? "fixed", credentials: decl.credentials ?? {} });
}

function resolveChannelReferenceIds(
decl: { agent: string; identity?: string },
decl: { agent?: string; identity?: string; mode?: "fixed" | "pairing" },
config: ProjectConfig,
provider: string,
state?: HashStateLookup,
): Record<string, string | null | undefined> {
if (decl.mode === "pairing" || !decl.agent) {
return { mode: "pairing" };
}
const agent = config.agents?.[decl.agent];
const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent";
const identity = decl.identity ?? config.defaults?.identity;
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/internal/providers/interface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,8 +97,8 @@ export interface ResolvedDeploymentRefs {
}

export interface ResolvedChannelRefs {
identity_id: string;
agent_id: string;
identity_id?: string;
agent_id?: string;
}

export interface DeploymentContext {
Expand Down
29 changes: 21 additions & 8 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,16 +354,21 @@ export class QoderAdapter implements ProviderAdapter {
}
if (type === "channel") {
const channelConfig = (raw.channel_config ?? {}) as Record<string, unknown>;
return compactDeep({
identity_id: raw.identity_id,
template_id: raw.template_id,
const mode = (raw.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
const normalized: Record<string, unknown> = {
identity_resolution: { mode },
channel_type: raw.channel_type,
name: raw.name,
enabled: raw.enabled,
channel_config: {
response_options: channelConfig.response_options ?? {},
},
});
};
if (mode === "fixed") {
normalized.identity_id = raw.identity_id;
normalized.template_id = raw.template_id;
}
return compactDeep(normalized);
}

return compactDeep({
Expand DownExpand Up@@ -564,12 +569,14 @@ export class QoderAdapter implements ProviderAdapter {

async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise<RemoteResource> {
const current = (await this.forwardClient.get(`/channels/${id}`)) as Record<string, unknown>;
if (current.channel_type !== decl.type) {
const currentMode = (current.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
if (current.channel_type !== decl.type || currentMode !== (decl.mode ?? "fixed")) {
await this.deleteChannel(id);
return this.createChannel(name, decl, refs);
}
const body = this.mapChannel(name, decl, refs);
delete body.channel_type;
delete body.identity_resolution;
const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record<string, unknown>;
return toRemoteResource(res);
}
Expand All@@ -579,9 +586,8 @@ export class QoderAdapter implements ProviderAdapter {
}

private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record<string, unknown> {
return {
identity_id: refs.identity_id,
template_id: refs.agent_id,
const mode = decl.mode ?? "fixed";
const body: Record<string, unknown> = {
channel_type: decl.type,
name: decl.name ?? name,
enabled: decl.enabled ?? true,
Expand All@@ -594,6 +600,13 @@ export class QoderAdapter implements ProviderAdapter {
},
},
};
if (mode === "pairing") {
body.identity_resolution = { mode: "pairing" };
} else {
body.identity_id = refs.identity_id;
body.template_id = refs.agent_id;
}
return body;
}

private async registerForwardVaults(vaultIds: string[]): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/internal/providers/qoder/mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -493,6 +493,9 @@ export function mapForwardTemplate(
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
else body.metadata = decl.metadata ?? {};
if (decl.environment_variables) body.environment_variables = decl.environment_variables;
// Sent on create and on update: Forward updates are merge-style, so omitting
// the field would silently keep whatever the Template already had.
if (decl.managed_tool_config) body.managed_tool_config = decl.managed_tool_config;

if (decl.tools) {
body.tools = [
Expand Down
15 changes: 12 additions & 3 deletions packages/sdk/src/internal/types/config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -177,10 +177,17 @@ export interface AgentDecl {
metadata?: Record<string, string>;
/** Qoder runtime environment variables. Forward delivery stores these as Template defaults. */
environment_variables?: Record<string, string>;
/** Provider-side tools the Agent Harness operates itself. Qoder Forward delivery only. */
managed_tool_config?: ManagedToolConfigDecl;
/** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */
delivery?: Record<ProviderName, AgentDeliveryDecl>;
}

export interface ManagedToolConfigDecl {
/** Replaces the provider's enabled managed-tool set; an empty array disables all of them. */
enabled_tools: string[];
}

export interface AgentDeliveryDecl {
type: "managed" | "forward";
}
Expand All@@ -189,12 +196,14 @@ export interface AgentDeliveryDecl {

export interface ChannelDecl {
provider?: ProviderName;
/** Logical Agent name. The provider adapter resolves its materialized remote resource. */
agent: string;
/** Logical Identity name. Falls back to defaults.identity. */
/** Logical Agent name. The provider adapter resolves its materialized remote resource. Required for `fixed` mode; ignored for `pairing` mode. */
agent?: string;
/** Logical Identity name. Falls back to defaults.identity. Required for `fixed` mode; ignored for `pairing` mode. */
identity?: string;
type: string;
name?: string;
/** Identity resolution mode. `fixed` binds the channel to one Identity/Template; `pairing` creates a transport-only channel used by Schedules/Sinks. Defaults to `fixed`. */
mode?: "fixed" | "pairing";
enabled?: boolean;
credentials?: Record<string, string>;
options?: Record<string, unknown>;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Update SDK and configuration by heimanba · Pull Request #88 · modelstudioai/OpenAgentPack · GitHub
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
9 changes: 9 additions & 0 deletions .changeset/pairing-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@openagentpack/sdk": minor
"@openagentpack/cli": minor
"@openagentpack/playground": minor
---

Add `mode: pairing` support for Qoder Channels.

`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`.
36 changes: 33 additions & 3 deletions docs/reference/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,9 +73,10 @@ External references are verified and recorded but never updated or deleted.
channels:
support-dingtalk:
provider: qoder # optional; inherits defaults.provider
agent: support-agent
identity: chen # optional; inherits defaults.identity
agent: support-agent # required for mode: fixed; ignored for mode: pairing
identity: chen # optional; inherits defaults.identity. ignored for mode: pairing
type: dingtalk
mode: fixed # optional; defaults to fixed
name: Support DingTalk # optional; defaults to the YAML key
enabled: true # optional; defaults to true
credentials:
Expand All@@ -86,7 +87,34 @@ channels:
include_thinking: false
```

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.
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `provider` | string | no | Provider name; inherits `defaults.provider`. |
| `agent` | string | conditional | Logical Agent name. Required for `fixed` mode; ignored for `pairing` mode. |
| `identity` | string | conditional | Logical Identity name; inherits `defaults.identity`. Required for `fixed` mode; ignored for `pairing` mode. |
| `type` | string | yes | Provider-specific channel type. Qoder supports `dingtalk`, `feishu`, and `wecom`; `wechat` is QR-only. |
| `mode` | `fixed` \| `pairing` | no | `fixed` (default) binds the channel to one Identity/Template. `pairing` creates a transport-only channel for Schedules/Sinks. |
| `name` | string | no | Display name; defaults to the YAML key. |
| `enabled` | boolean | no | Defaults to `true`. |
| `credentials` | map | conditional | Provider-specific credentials. Required for credential-based channel types. |
| `options` | map | no | Provider-specific response options, e.g. `include_tool_calls`, `include_thinking`. |

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.

### Managed tool config

`managed_tool_config` declares the provider-operated tools an Agent Harness runs
itself, rather than tools the model calls through the sandbox. Schedule
management is the current use: enabling `create_forward_schedule`,
`list_forward_schedules`, and `delete_forward_schedule` lets an end user create
and cancel Schedules in natural language from a Web or IM Channel conversation.

`enabled_tools` replaces the provider's whole enabled set, so an empty array
turns every managed tool off. Omitting the field entirely sends nothing: because
Qoder Forward Template updates are merge-style, an undeclared field leaves
whatever the remote Template already had. Declare it whenever the tools matter —
a Template recreated from scratch (after a destroy, a manual deletion, or lost
state) otherwise comes back with no managed tools and no error.

## Provider configuration

Expand DownExpand Up@@ -253,6 +281,7 @@ agents:
vault: <string>
memory_stores: [ <string> ]
environment_variables: { <key>: <string> } # Qoder only
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
Expand All@@ -274,6 +303,7 @@ agents:
| `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`. |
| `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. |
| `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
67 changes: 43 additions & 24 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,10 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics:
}

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

if (providerName === "qoder") {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
if (channel.mode !== "pairing" && channel.agent) {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
}
}
const requiredCredentials: Record<string, string[]> = {
dingtalk: ["client_id", "client_secret"],
Expand DownExpand Up@@ -306,6 +311,13 @@ export function collectProviderCapabilities(
address,
);
}
if (delivery !== "forward" && agent.managed_tool_config) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.forward_required`,
`agent.${name}: managed_tool_config applies to Forward Templates; set delivery.${providerName}.type: forward or remove it.`,
address,
);
}
if (delivery === "forward" && !isSupported(caps, "template")) {
diagnostics.error(
`${providerName}.agent.delivery.forward.unsupported`,
Expand DownExpand Up@@ -463,6 +475,13 @@ export function collectProviderCapabilities(
{ type: "agent", name, provider: providerName },
);
}
if (agent.managed_tool_config && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.unsupported`,
`agent.${name}: managed_tool_config 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
7 changes: 7 additions & 0 deletions packages/sdk/src/internal/executor/resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -179,6 +179,13 @@ export function resolveChannelRefs(
const channel = config.channels?.[channelName];
if (!channel) throw new UserError(`Channel '${channelName}' not found in config`);

if (channel.mode === "pairing") {
return {};
}

if (!channel.agent) {
throw new UserError(`Channel '${channelName}' is fixed mode and must declare agent`);
}
const agent = config.agents?.[channel.agent];
if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`);
const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent";
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/graph/dependency.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,6 +190,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str
const channelAddr: ResourceAddress = { type: "channel", name, provider };
addNode(channelAddr);

if (decl.mode === "pairing" || !decl.agent) continue;

const agentDecl = config.agents?.[decl.agent];
const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent";
const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider };
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/internal/parser/schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,6 +231,10 @@ const agentDeliverySchema = z.object({
type: z.enum(["managed", "forward"]),
});

const managedToolConfigSchema = z.object({
enabled_tools: z.array(z.string().min(1)),
});

const sessionGithubRepoResourceSchema = z.object({
type: z.literal("github_repository"),
url: z.string().url(),
Expand DownExpand Up@@ -261,15 +265,17 @@ const agentSchema = z.object({
multiagent: multiagentSchema.optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.record(z.string().min(1), z.string()).optional(),
managed_tool_config: managedToolConfigSchema.optional(),
delivery: z.record(z.string(), agentDeliverySchema).optional(),
});

const channelSchema = z.object({
provider: z.string().optional(),
agent: z.string().min(1),
agent: z.string().min(1).optional(),
identity: z.string().min(1).optional(),
type: z.string().min(1),
name: z.string().trim().min(1).optional(),
mode: z.enum(["fixed", "pairing"]).optional().default("fixed"),
enabled: z.boolean().optional(),
credentials: z.record(z.string(), coerceString).optional(),
options: z.record(z.string(), z.unknown()).optional(),
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/internal/planner/hasher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,15 +63,18 @@ export function computeReplacementFingerprint(address: ResourceAddress, config:
if (address.type !== "channel") return undefined;
const decl = config.channels?.[address.name];
if (!decl) return undefined;
return contentHash({ channel_type: decl.type, credentials: decl.credentials ?? {} });
return contentHash({ channel_type: decl.type, mode: decl.mode ?? "fixed", credentials: decl.credentials ?? {} });
}

function resolveChannelReferenceIds(
decl: { agent: string; identity?: string },
decl: { agent?: string; identity?: string; mode?: "fixed" | "pairing" },
config: ProjectConfig,
provider: string,
state?: HashStateLookup,
): Record<string, string | null | undefined> {
if (decl.mode === "pairing" || !decl.agent) {
return { mode: "pairing" };
}
const agent = config.agents?.[decl.agent];
const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent";
const identity = decl.identity ?? config.defaults?.identity;
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/internal/providers/interface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,8 +97,8 @@ export interface ResolvedDeploymentRefs {
}

export interface ResolvedChannelRefs {
identity_id: string;
agent_id: string;
identity_id?: string;
agent_id?: string;
}

export interface DeploymentContext {
Expand Down
29 changes: 21 additions & 8 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,16 +354,21 @@ export class QoderAdapter implements ProviderAdapter {
}
if (type === "channel") {
const channelConfig = (raw.channel_config ?? {}) as Record<string, unknown>;
return compactDeep({
identity_id: raw.identity_id,
template_id: raw.template_id,
const mode = (raw.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
const normalized: Record<string, unknown> = {
identity_resolution: { mode },
channel_type: raw.channel_type,
name: raw.name,
enabled: raw.enabled,
channel_config: {
response_options: channelConfig.response_options ?? {},
},
});
};
if (mode === "fixed") {
normalized.identity_id = raw.identity_id;
normalized.template_id = raw.template_id;
}
return compactDeep(normalized);
}

return compactDeep({
Expand DownExpand Up@@ -564,12 +569,14 @@ export class QoderAdapter implements ProviderAdapter {

async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise<RemoteResource> {
const current = (await this.forwardClient.get(`/channels/${id}`)) as Record<string, unknown>;
if (current.channel_type !== decl.type) {
const currentMode = (current.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
if (current.channel_type !== decl.type || currentMode !== (decl.mode ?? "fixed")) {
await this.deleteChannel(id);
return this.createChannel(name, decl, refs);
}
const body = this.mapChannel(name, decl, refs);
delete body.channel_type;
delete body.identity_resolution;
const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record<string, unknown>;
return toRemoteResource(res);
}
Expand All@@ -579,9 +586,8 @@ export class QoderAdapter implements ProviderAdapter {
}

private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record<string, unknown> {
return {
identity_id: refs.identity_id,
template_id: refs.agent_id,
const mode = decl.mode ?? "fixed";
const body: Record<string, unknown> = {
channel_type: decl.type,
name: decl.name ?? name,
enabled: decl.enabled ?? true,
Expand All@@ -594,6 +600,13 @@ export class QoderAdapter implements ProviderAdapter {
},
},
};
if (mode === "pairing") {
body.identity_resolution = { mode: "pairing" };
} else {
body.identity_id = refs.identity_id;
body.template_id = refs.agent_id;
}
return body;
}

private async registerForwardVaults(vaultIds: string[]): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/internal/providers/qoder/mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -493,6 +493,9 @@ export function mapForwardTemplate(
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
else body.metadata = decl.metadata ?? {};
if (decl.environment_variables) body.environment_variables = decl.environment_variables;
// Sent on create and on update: Forward updates are merge-style, so omitting
// the field would silently keep whatever the Template already had.
if (decl.managed_tool_config) body.managed_tool_config = decl.managed_tool_config;

if (decl.tools) {
body.tools = [
Expand Down
15 changes: 12 additions & 3 deletions packages/sdk/src/internal/types/config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -177,10 +177,17 @@ export interface AgentDecl {
metadata?: Record<string, string>;
/** Qoder runtime environment variables. Forward delivery stores these as Template defaults. */
environment_variables?: Record<string, string>;
/** Provider-side tools the Agent Harness operates itself. Qoder Forward delivery only. */
managed_tool_config?: ManagedToolConfigDecl;
/** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */
delivery?: Record<ProviderName, AgentDeliveryDecl>;
}

export interface ManagedToolConfigDecl {
/** Replaces the provider's enabled managed-tool set; an empty array disables all of them. */
enabled_tools: string[];
}

export interface AgentDeliveryDecl {
type: "managed" | "forward";
}
Expand All@@ -189,12 +196,14 @@ export interface AgentDeliveryDecl {

export interface ChannelDecl {
provider?: ProviderName;
/** Logical Agent name. The provider adapter resolves its materialized remote resource. */
agent: string;
/** Logical Identity name. Falls back to defaults.identity. */
/** Logical Agent name. The provider adapter resolves its materialized remote resource. Required for `fixed` mode; ignored for `pairing` mode. */
agent?: string;
/** Logical Identity name. Falls back to defaults.identity. Required for `fixed` mode; ignored for `pairing` mode. */
identity?: string;
type: string;
name?: string;
/** Identity resolution mode. `fixed` binds the channel to one Identity/Template; `pairing` creates a transport-only channel used by Schedules/Sinks. Defaults to `fixed`. */
mode?: "fixed" | "pairing";
enabled?: boolean;
credentials?: Record<string, string>;
options?: Record<string, unknown>;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Update SDK and configuration by heimanba · Pull Request #88 · modelstudioai/OpenAgentPack · GitHub
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
9 changes: 9 additions & 0 deletions .changeset/pairing-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@openagentpack/sdk": minor
"@openagentpack/cli": minor
"@openagentpack/playground": minor
---

Add `mode: pairing` support for Qoder Channels.

`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`.
36 changes: 33 additions & 3 deletions docs/reference/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,9 +73,10 @@ External references are verified and recorded but never updated or deleted.
channels:
support-dingtalk:
provider: qoder # optional; inherits defaults.provider
agent: support-agent
identity: chen # optional; inherits defaults.identity
agent: support-agent # required for mode: fixed; ignored for mode: pairing
identity: chen # optional; inherits defaults.identity. ignored for mode: pairing
type: dingtalk
mode: fixed # optional; defaults to fixed
name: Support DingTalk # optional; defaults to the YAML key
enabled: true # optional; defaults to true
credentials:
Expand All@@ -86,7 +87,34 @@ channels:
include_thinking: false
```

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.
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `provider` | string | no | Provider name; inherits `defaults.provider`. |
| `agent` | string | conditional | Logical Agent name. Required for `fixed` mode; ignored for `pairing` mode. |
| `identity` | string | conditional | Logical Identity name; inherits `defaults.identity`. Required for `fixed` mode; ignored for `pairing` mode. |
| `type` | string | yes | Provider-specific channel type. Qoder supports `dingtalk`, `feishu`, and `wecom`; `wechat` is QR-only. |
| `mode` | `fixed` \| `pairing` | no | `fixed` (default) binds the channel to one Identity/Template. `pairing` creates a transport-only channel for Schedules/Sinks. |
| `name` | string | no | Display name; defaults to the YAML key. |
| `enabled` | boolean | no | Defaults to `true`. |
| `credentials` | map | conditional | Provider-specific credentials. Required for credential-based channel types. |
| `options` | map | no | Provider-specific response options, e.g. `include_tool_calls`, `include_thinking`. |

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.

### Managed tool config

`managed_tool_config` declares the provider-operated tools an Agent Harness runs
itself, rather than tools the model calls through the sandbox. Schedule
management is the current use: enabling `create_forward_schedule`,
`list_forward_schedules`, and `delete_forward_schedule` lets an end user create
and cancel Schedules in natural language from a Web or IM Channel conversation.

`enabled_tools` replaces the provider's whole enabled set, so an empty array
turns every managed tool off. Omitting the field entirely sends nothing: because
Qoder Forward Template updates are merge-style, an undeclared field leaves
whatever the remote Template already had. Declare it whenever the tools matter —
a Template recreated from scratch (after a destroy, a manual deletion, or lost
state) otherwise comes back with no managed tools and no error.

## Provider configuration

Expand DownExpand Up@@ -253,6 +281,7 @@ agents:
vault: <string>
memory_stores: [ <string> ]
environment_variables: { <key>: <string> } # Qoder only
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
Expand All@@ -274,6 +303,7 @@ agents:
| `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`. |
| `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. |
| `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
67 changes: 43 additions & 24 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,10 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics:
}

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

if (providerName === "qoder") {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
if (channel.mode !== "pairing" && channel.agent) {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
}
}
const requiredCredentials: Record<string, string[]> = {
dingtalk: ["client_id", "client_secret"],
Expand DownExpand Up@@ -306,6 +311,13 @@ export function collectProviderCapabilities(
address,
);
}
if (delivery !== "forward" && agent.managed_tool_config) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.forward_required`,
`agent.${name}: managed_tool_config applies to Forward Templates; set delivery.${providerName}.type: forward or remove it.`,
address,
);
}
if (delivery === "forward" && !isSupported(caps, "template")) {
diagnostics.error(
`${providerName}.agent.delivery.forward.unsupported`,
Expand DownExpand Up@@ -463,6 +475,13 @@ export function collectProviderCapabilities(
{ type: "agent", name, provider: providerName },
);
}
if (agent.managed_tool_config && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.unsupported`,
`agent.${name}: managed_tool_config 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
7 changes: 7 additions & 0 deletions packages/sdk/src/internal/executor/resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -179,6 +179,13 @@ export function resolveChannelRefs(
const channel = config.channels?.[channelName];
if (!channel) throw new UserError(`Channel '${channelName}' not found in config`);

if (channel.mode === "pairing") {
return {};
}

if (!channel.agent) {
throw new UserError(`Channel '${channelName}' is fixed mode and must declare agent`);
}
const agent = config.agents?.[channel.agent];
if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`);
const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent";
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/graph/dependency.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,6 +190,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str
const channelAddr: ResourceAddress = { type: "channel", name, provider };
addNode(channelAddr);

if (decl.mode === "pairing" || !decl.agent) continue;

const agentDecl = config.agents?.[decl.agent];
const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent";
const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider };
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/internal/parser/schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,6 +231,10 @@ const agentDeliverySchema = z.object({
type: z.enum(["managed", "forward"]),
});

const managedToolConfigSchema = z.object({
enabled_tools: z.array(z.string().min(1)),
});

const sessionGithubRepoResourceSchema = z.object({
type: z.literal("github_repository"),
url: z.string().url(),
Expand DownExpand Up@@ -261,15 +265,17 @@ const agentSchema = z.object({
multiagent: multiagentSchema.optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.record(z.string().min(1), z.string()).optional(),
managed_tool_config: managedToolConfigSchema.optional(),
delivery: z.record(z.string(), agentDeliverySchema).optional(),
});

const channelSchema = z.object({
provider: z.string().optional(),
agent: z.string().min(1),
agent: z.string().min(1).optional(),
identity: z.string().min(1).optional(),
type: z.string().min(1),
name: z.string().trim().min(1).optional(),
mode: z.enum(["fixed", "pairing"]).optional().default("fixed"),
enabled: z.boolean().optional(),
credentials: z.record(z.string(), coerceString).optional(),
options: z.record(z.string(), z.unknown()).optional(),
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/internal/planner/hasher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,15 +63,18 @@ export function computeReplacementFingerprint(address: ResourceAddress, config:
if (address.type !== "channel") return undefined;
const decl = config.channels?.[address.name];
if (!decl) return undefined;
return contentHash({ channel_type: decl.type, credentials: decl.credentials ?? {} });
return contentHash({ channel_type: decl.type, mode: decl.mode ?? "fixed", credentials: decl.credentials ?? {} });
}

function resolveChannelReferenceIds(
decl: { agent: string; identity?: string },
decl: { agent?: string; identity?: string; mode?: "fixed" | "pairing" },
config: ProjectConfig,
provider: string,
state?: HashStateLookup,
): Record<string, string | null | undefined> {
if (decl.mode === "pairing" || !decl.agent) {
return { mode: "pairing" };
}
const agent = config.agents?.[decl.agent];
const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent";
const identity = decl.identity ?? config.defaults?.identity;
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/internal/providers/interface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,8 +97,8 @@ export interface ResolvedDeploymentRefs {
}

export interface ResolvedChannelRefs {
identity_id: string;
agent_id: string;
identity_id?: string;
agent_id?: string;
}

export interface DeploymentContext {
Expand Down
29 changes: 21 additions & 8 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,16 +354,21 @@ export class QoderAdapter implements ProviderAdapter {
}
if (type === "channel") {
const channelConfig = (raw.channel_config ?? {}) as Record<string, unknown>;
return compactDeep({
identity_id: raw.identity_id,
template_id: raw.template_id,
const mode = (raw.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
const normalized: Record<string, unknown> = {
identity_resolution: { mode },
channel_type: raw.channel_type,
name: raw.name,
enabled: raw.enabled,
channel_config: {
response_options: channelConfig.response_options ?? {},
},
});
};
if (mode === "fixed") {
normalized.identity_id = raw.identity_id;
normalized.template_id = raw.template_id;
}
return compactDeep(normalized);
}

return compactDeep({
Expand DownExpand Up@@ -564,12 +569,14 @@ export class QoderAdapter implements ProviderAdapter {

async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise<RemoteResource> {
const current = (await this.forwardClient.get(`/channels/${id}`)) as Record<string, unknown>;
if (current.channel_type !== decl.type) {
const currentMode = (current.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
if (current.channel_type !== decl.type || currentMode !== (decl.mode ?? "fixed")) {
await this.deleteChannel(id);
return this.createChannel(name, decl, refs);
}
const body = this.mapChannel(name, decl, refs);
delete body.channel_type;
delete body.identity_resolution;
const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record<string, unknown>;
return toRemoteResource(res);
}
Expand All@@ -579,9 +586,8 @@ export class QoderAdapter implements ProviderAdapter {
}

private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record<string, unknown> {
return {
identity_id: refs.identity_id,
template_id: refs.agent_id,
const mode = decl.mode ?? "fixed";
const body: Record<string, unknown> = {
channel_type: decl.type,
name: decl.name ?? name,
enabled: decl.enabled ?? true,
Expand All@@ -594,6 +600,13 @@ export class QoderAdapter implements ProviderAdapter {
},
},
};
if (mode === "pairing") {
body.identity_resolution = { mode: "pairing" };
} else {
body.identity_id = refs.identity_id;
body.template_id = refs.agent_id;
}
return body;
}

private async registerForwardVaults(vaultIds: string[]): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/internal/providers/qoder/mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -493,6 +493,9 @@ export function mapForwardTemplate(
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
else body.metadata = decl.metadata ?? {};
if (decl.environment_variables) body.environment_variables = decl.environment_variables;
// Sent on create and on update: Forward updates are merge-style, so omitting
// the field would silently keep whatever the Template already had.
if (decl.managed_tool_config) body.managed_tool_config = decl.managed_tool_config;

if (decl.tools) {
body.tools = [
Expand Down
15 changes: 12 additions & 3 deletions packages/sdk/src/internal/types/config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -177,10 +177,17 @@ export interface AgentDecl {
metadata?: Record<string, string>;
/** Qoder runtime environment variables. Forward delivery stores these as Template defaults. */
environment_variables?: Record<string, string>;
/** Provider-side tools the Agent Harness operates itself. Qoder Forward delivery only. */
managed_tool_config?: ManagedToolConfigDecl;
/** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */
delivery?: Record<ProviderName, AgentDeliveryDecl>;
}

export interface ManagedToolConfigDecl {
/** Replaces the provider's enabled managed-tool set; an empty array disables all of them. */
enabled_tools: string[];
}

export interface AgentDeliveryDecl {
type: "managed" | "forward";
}
Expand All@@ -189,12 +196,14 @@ export interface AgentDeliveryDecl {

export interface ChannelDecl {
provider?: ProviderName;
/** Logical Agent name. The provider adapter resolves its materialized remote resource. */
agent: string;
/** Logical Identity name. Falls back to defaults.identity. */
/** Logical Agent name. The provider adapter resolves its materialized remote resource. Required for `fixed` mode; ignored for `pairing` mode. */
agent?: string;
/** Logical Identity name. Falls back to defaults.identity. Required for `fixed` mode; ignored for `pairing` mode. */
identity?: string;
type: string;
name?: string;
/** Identity resolution mode. `fixed` binds the channel to one Identity/Template; `pairing` creates a transport-only channel used by Schedules/Sinks. Defaults to `fixed`. */
mode?: "fixed" | "pairing";
enabled?: boolean;
credentials?: Record<string, string>;
options?: Record<string, unknown>;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Update SDK and configuration by heimanba · Pull Request #88 · modelstudioai/OpenAgentPack · GitHub
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
9 changes: 9 additions & 0 deletions .changeset/pairing-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@openagentpack/sdk": minor
"@openagentpack/cli": minor
"@openagentpack/playground": minor
---

Add `mode: pairing` support for Qoder Channels.

`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`.
36 changes: 33 additions & 3 deletions docs/reference/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,9 +73,10 @@ External references are verified and recorded but never updated or deleted.
channels:
support-dingtalk:
provider: qoder # optional; inherits defaults.provider
agent: support-agent
identity: chen # optional; inherits defaults.identity
agent: support-agent # required for mode: fixed; ignored for mode: pairing
identity: chen # optional; inherits defaults.identity. ignored for mode: pairing
type: dingtalk
mode: fixed # optional; defaults to fixed
name: Support DingTalk # optional; defaults to the YAML key
enabled: true # optional; defaults to true
credentials:
Expand All@@ -86,7 +87,34 @@ channels:
include_thinking: false
```

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.
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `provider` | string | no | Provider name; inherits `defaults.provider`. |
| `agent` | string | conditional | Logical Agent name. Required for `fixed` mode; ignored for `pairing` mode. |
| `identity` | string | conditional | Logical Identity name; inherits `defaults.identity`. Required for `fixed` mode; ignored for `pairing` mode. |
| `type` | string | yes | Provider-specific channel type. Qoder supports `dingtalk`, `feishu`, and `wecom`; `wechat` is QR-only. |
| `mode` | `fixed` \| `pairing` | no | `fixed` (default) binds the channel to one Identity/Template. `pairing` creates a transport-only channel for Schedules/Sinks. |
| `name` | string | no | Display name; defaults to the YAML key. |
| `enabled` | boolean | no | Defaults to `true`. |
| `credentials` | map | conditional | Provider-specific credentials. Required for credential-based channel types. |
| `options` | map | no | Provider-specific response options, e.g. `include_tool_calls`, `include_thinking`. |

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.

### Managed tool config

`managed_tool_config` declares the provider-operated tools an Agent Harness runs
itself, rather than tools the model calls through the sandbox. Schedule
management is the current use: enabling `create_forward_schedule`,
`list_forward_schedules`, and `delete_forward_schedule` lets an end user create
and cancel Schedules in natural language from a Web or IM Channel conversation.

`enabled_tools` replaces the provider's whole enabled set, so an empty array
turns every managed tool off. Omitting the field entirely sends nothing: because
Qoder Forward Template updates are merge-style, an undeclared field leaves
whatever the remote Template already had. Declare it whenever the tools matter —
a Template recreated from scratch (after a destroy, a manual deletion, or lost
state) otherwise comes back with no managed tools and no error.

## Provider configuration

Expand DownExpand Up@@ -253,6 +281,7 @@ agents:
vault: <string>
memory_stores: [ <string> ]
environment_variables: { <key>: <string> } # Qoder only
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
Expand All@@ -274,6 +303,7 @@ agents:
| `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`. |
| `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. |
| `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
67 changes: 43 additions & 24 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,10 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics:
}

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

if (providerName === "qoder") {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
if (channel.mode !== "pairing" && channel.agent) {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
}
}
const requiredCredentials: Record<string, string[]> = {
dingtalk: ["client_id", "client_secret"],
Expand DownExpand Up@@ -306,6 +311,13 @@ export function collectProviderCapabilities(
address,
);
}
if (delivery !== "forward" && agent.managed_tool_config) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.forward_required`,
`agent.${name}: managed_tool_config applies to Forward Templates; set delivery.${providerName}.type: forward or remove it.`,
address,
);
}
if (delivery === "forward" && !isSupported(caps, "template")) {
diagnostics.error(
`${providerName}.agent.delivery.forward.unsupported`,
Expand DownExpand Up@@ -463,6 +475,13 @@ export function collectProviderCapabilities(
{ type: "agent", name, provider: providerName },
);
}
if (agent.managed_tool_config && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.unsupported`,
`agent.${name}: managed_tool_config 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
7 changes: 7 additions & 0 deletions packages/sdk/src/internal/executor/resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -179,6 +179,13 @@ export function resolveChannelRefs(
const channel = config.channels?.[channelName];
if (!channel) throw new UserError(`Channel '${channelName}' not found in config`);

if (channel.mode === "pairing") {
return {};
}

if (!channel.agent) {
throw new UserError(`Channel '${channelName}' is fixed mode and must declare agent`);
}
const agent = config.agents?.[channel.agent];
if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`);
const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent";
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/graph/dependency.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,6 +190,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str
const channelAddr: ResourceAddress = { type: "channel", name, provider };
addNode(channelAddr);

if (decl.mode === "pairing" || !decl.agent) continue;

const agentDecl = config.agents?.[decl.agent];
const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent";
const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider };
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/internal/parser/schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,6 +231,10 @@ const agentDeliverySchema = z.object({
type: z.enum(["managed", "forward"]),
});

const managedToolConfigSchema = z.object({
enabled_tools: z.array(z.string().min(1)),
});

const sessionGithubRepoResourceSchema = z.object({
type: z.literal("github_repository"),
url: z.string().url(),
Expand DownExpand Up@@ -261,15 +265,17 @@ const agentSchema = z.object({
multiagent: multiagentSchema.optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.record(z.string().min(1), z.string()).optional(),
managed_tool_config: managedToolConfigSchema.optional(),
delivery: z.record(z.string(), agentDeliverySchema).optional(),
});

const channelSchema = z.object({
provider: z.string().optional(),
agent: z.string().min(1),
agent: z.string().min(1).optional(),
identity: z.string().min(1).optional(),
type: z.string().min(1),
name: z.string().trim().min(1).optional(),
mode: z.enum(["fixed", "pairing"]).optional().default("fixed"),
enabled: z.boolean().optional(),
credentials: z.record(z.string(), coerceString).optional(),
options: z.record(z.string(), z.unknown()).optional(),
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/internal/planner/hasher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,15 +63,18 @@ export function computeReplacementFingerprint(address: ResourceAddress, config:
if (address.type !== "channel") return undefined;
const decl = config.channels?.[address.name];
if (!decl) return undefined;
return contentHash({ channel_type: decl.type, credentials: decl.credentials ?? {} });
return contentHash({ channel_type: decl.type, mode: decl.mode ?? "fixed", credentials: decl.credentials ?? {} });
}

function resolveChannelReferenceIds(
decl: { agent: string; identity?: string },
decl: { agent?: string; identity?: string; mode?: "fixed" | "pairing" },
config: ProjectConfig,
provider: string,
state?: HashStateLookup,
): Record<string, string | null | undefined> {
if (decl.mode === "pairing" || !decl.agent) {
return { mode: "pairing" };
}
const agent = config.agents?.[decl.agent];
const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent";
const identity = decl.identity ?? config.defaults?.identity;
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/internal/providers/interface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,8 +97,8 @@ export interface ResolvedDeploymentRefs {
}

export interface ResolvedChannelRefs {
identity_id: string;
agent_id: string;
identity_id?: string;
agent_id?: string;
}

export interface DeploymentContext {
Expand Down
29 changes: 21 additions & 8 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,16 +354,21 @@ export class QoderAdapter implements ProviderAdapter {
}
if (type === "channel") {
const channelConfig = (raw.channel_config ?? {}) as Record<string, unknown>;
return compactDeep({
identity_id: raw.identity_id,
template_id: raw.template_id,
const mode = (raw.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
const normalized: Record<string, unknown> = {
identity_resolution: { mode },
channel_type: raw.channel_type,
name: raw.name,
enabled: raw.enabled,
channel_config: {
response_options: channelConfig.response_options ?? {},
},
});
};
if (mode === "fixed") {
normalized.identity_id = raw.identity_id;
normalized.template_id = raw.template_id;
}
return compactDeep(normalized);
}

return compactDeep({
Expand DownExpand Up@@ -564,12 +569,14 @@ export class QoderAdapter implements ProviderAdapter {

async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise<RemoteResource> {
const current = (await this.forwardClient.get(`/channels/${id}`)) as Record<string, unknown>;
if (current.channel_type !== decl.type) {
const currentMode = (current.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
if (current.channel_type !== decl.type || currentMode !== (decl.mode ?? "fixed")) {
await this.deleteChannel(id);
return this.createChannel(name, decl, refs);
}
const body = this.mapChannel(name, decl, refs);
delete body.channel_type;
delete body.identity_resolution;
const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record<string, unknown>;
return toRemoteResource(res);
}
Expand All@@ -579,9 +586,8 @@ export class QoderAdapter implements ProviderAdapter {
}

private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record<string, unknown> {
return {
identity_id: refs.identity_id,
template_id: refs.agent_id,
const mode = decl.mode ?? "fixed";
const body: Record<string, unknown> = {
channel_type: decl.type,
name: decl.name ?? name,
enabled: decl.enabled ?? true,
Expand All@@ -594,6 +600,13 @@ export class QoderAdapter implements ProviderAdapter {
},
},
};
if (mode === "pairing") {
body.identity_resolution = { mode: "pairing" };
} else {
body.identity_id = refs.identity_id;
body.template_id = refs.agent_id;
}
return body;
}

private async registerForwardVaults(vaultIds: string[]): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/internal/providers/qoder/mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -493,6 +493,9 @@ export function mapForwardTemplate(
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
else body.metadata = decl.metadata ?? {};
if (decl.environment_variables) body.environment_variables = decl.environment_variables;
// Sent on create and on update: Forward updates are merge-style, so omitting
// the field would silently keep whatever the Template already had.
if (decl.managed_tool_config) body.managed_tool_config = decl.managed_tool_config;

if (decl.tools) {
body.tools = [
Expand Down
15 changes: 12 additions & 3 deletions packages/sdk/src/internal/types/config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -177,10 +177,17 @@ export interface AgentDecl {
metadata?: Record<string, string>;
/** Qoder runtime environment variables. Forward delivery stores these as Template defaults. */
environment_variables?: Record<string, string>;
/** Provider-side tools the Agent Harness operates itself. Qoder Forward delivery only. */
managed_tool_config?: ManagedToolConfigDecl;
/** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */
delivery?: Record<ProviderName, AgentDeliveryDecl>;
}

export interface ManagedToolConfigDecl {
/** Replaces the provider's enabled managed-tool set; an empty array disables all of them. */
enabled_tools: string[];
}

export interface AgentDeliveryDecl {
type: "managed" | "forward";
}
Expand All@@ -189,12 +196,14 @@ export interface AgentDeliveryDecl {

export interface ChannelDecl {
provider?: ProviderName;
/** Logical Agent name. The provider adapter resolves its materialized remote resource. */
agent: string;
/** Logical Identity name. Falls back to defaults.identity. */
/** Logical Agent name. The provider adapter resolves its materialized remote resource. Required for `fixed` mode; ignored for `pairing` mode. */
agent?: string;
/** Logical Identity name. Falls back to defaults.identity. Required for `fixed` mode; ignored for `pairing` mode. */
identity?: string;
type: string;
name?: string;
/** Identity resolution mode. `fixed` binds the channel to one Identity/Template; `pairing` creates a transport-only channel used by Schedules/Sinks. Defaults to `fixed`. */
mode?: "fixed" | "pairing";
enabled?: boolean;
credentials?: Record<string, string>;
options?: Record<string, unknown>;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Update SDK and configuration by heimanba · Pull Request #88 · modelstudioai/OpenAgentPack · GitHub
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
9 changes: 9 additions & 0 deletions .changeset/pairing-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@openagentpack/sdk": minor
"@openagentpack/cli": minor
"@openagentpack/playground": minor
---

Add `mode: pairing` support for Qoder Channels.

`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`.
36 changes: 33 additions & 3 deletions docs/reference/configuration.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,9 +73,10 @@ External references are verified and recorded but never updated or deleted.
channels:
support-dingtalk:
provider: qoder # optional; inherits defaults.provider
agent: support-agent
identity: chen # optional; inherits defaults.identity
agent: support-agent # required for mode: fixed; ignored for mode: pairing
identity: chen # optional; inherits defaults.identity. ignored for mode: pairing
type: dingtalk
mode: fixed # optional; defaults to fixed
name: Support DingTalk # optional; defaults to the YAML key
enabled: true # optional; defaults to true
credentials:
Expand All@@ -86,7 +87,34 @@ channels:
include_thinking: false
```

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.
| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `provider` | string | no | Provider name; inherits `defaults.provider`. |
| `agent` | string | conditional | Logical Agent name. Required for `fixed` mode; ignored for `pairing` mode. |
| `identity` | string | conditional | Logical Identity name; inherits `defaults.identity`. Required for `fixed` mode; ignored for `pairing` mode. |
| `type` | string | yes | Provider-specific channel type. Qoder supports `dingtalk`, `feishu`, and `wecom`; `wechat` is QR-only. |
| `mode` | `fixed` \| `pairing` | no | `fixed` (default) binds the channel to one Identity/Template. `pairing` creates a transport-only channel for Schedules/Sinks. |
| `name` | string | no | Display name; defaults to the YAML key. |
| `enabled` | boolean | no | Defaults to `true`. |
| `credentials` | map | conditional | Provider-specific credentials. Required for credential-based channel types. |
| `options` | map | no | Provider-specific response options, e.g. `include_tool_calls`, `include_thinking`. |

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.

### Managed tool config

`managed_tool_config` declares the provider-operated tools an Agent Harness runs
itself, rather than tools the model calls through the sandbox. Schedule
management is the current use: enabling `create_forward_schedule`,
`list_forward_schedules`, and `delete_forward_schedule` lets an end user create
and cancel Schedules in natural language from a Web or IM Channel conversation.

`enabled_tools` replaces the provider's whole enabled set, so an empty array
turns every managed tool off. Omitting the field entirely sends nothing: because
Qoder Forward Template updates are merge-style, an undeclared field leaves
whatever the remote Template already had. Declare it whenever the tools matter —
a Template recreated from scratch (after a destroy, a manual deletion, or lost
state) otherwise comes back with no managed tools and no error.

## Provider configuration

Expand DownExpand Up@@ -253,6 +281,7 @@ agents:
vault: <string>
memory_stores: [ <string> ]
environment_variables: { <key>: <string> } # Qoder only
managed_tool_config: { enabled_tools: [ <string> ] } # Qoder Forward delivery only
resources: [ SessionResource ]
multiagent: { type: "coordinator", agents: [...] }
metadata: { <key>: <string> }
Expand All@@ -274,6 +303,7 @@ agents:
| `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`. |
| `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. |
| `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
67 changes: 43 additions & 24 deletions packages/sdk/src/internal/core/validate-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,7 +113,10 @@ export function collectReferenceDiagnostics(config: ProjectConfig, diagnostics:
}

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

if (providerName === "qoder") {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
if (channel.mode !== "pairing" && channel.agent) {
const agent = config.agents?.[channel.agent];
if (agent?.provider && agent.provider !== providerName) {
diagnostics.error(
"config.channel.agent.provider_mismatch",
`channel.${name}: agent '${channel.agent}' is pinned to provider '${agent.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
const identityName = channel.identity ?? config.defaults?.identity;
const identity = identityName ? config.identities?.[identityName] : undefined;
if (identity?.provider && identity.provider !== providerName) {
diagnostics.error(
"config.channel.identity.provider_mismatch",
`channel.${name}: identity '${identityName}' is pinned to provider '${identity.provider}'.`,
{ type: "channel", name, provider: providerName },
);
}
if (agent && agent.delivery?.qoder?.type !== "forward") {
diagnostics.error(
"qoder.channel.forward_template.required",
`channel.${name}: Qoder Channels require agent '${channel.agent}' to use delivery.qoder.type: forward.`,
{ type: "channel", name, provider: providerName },
);
}
}
const requiredCredentials: Record<string, string[]> = {
dingtalk: ["client_id", "client_secret"],
Expand DownExpand Up@@ -306,6 +311,13 @@ export function collectProviderCapabilities(
address,
);
}
if (delivery !== "forward" && agent.managed_tool_config) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.forward_required`,
`agent.${name}: managed_tool_config applies to Forward Templates; set delivery.${providerName}.type: forward or remove it.`,
address,
);
}
if (delivery === "forward" && !isSupported(caps, "template")) {
diagnostics.error(
`${providerName}.agent.delivery.forward.unsupported`,
Expand DownExpand Up@@ -463,6 +475,13 @@ export function collectProviderCapabilities(
{ type: "agent", name, provider: providerName },
);
}
if (agent.managed_tool_config && (!agent.provider || agent.provider === providerName)) {
diagnostics.error(
`${providerName}.agent.managed_tool_config.unsupported`,
`agent.${name}: managed_tool_config 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
7 changes: 7 additions & 0 deletions packages/sdk/src/internal/executor/resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -179,6 +179,13 @@ export function resolveChannelRefs(
const channel = config.channels?.[channelName];
if (!channel) throw new UserError(`Channel '${channelName}' not found in config`);

if (channel.mode === "pairing") {
return {};
}

if (!channel.agent) {
throw new UserError(`Channel '${channelName}' is fixed mode and must declare agent`);
}
const agent = config.agents?.[channel.agent];
if (!agent) throw new UserError(`Channel '${channelName}' references unknown agent '${channel.agent}'`);
const agentType = agent.delivery?.[provider]?.type === "forward" ? "template" : "agent";
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/internal/graph/dependency.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -190,6 +190,8 @@ export function buildDependencyGraph(config: ProjectConfig, targetProviders: str
const channelAddr: ResourceAddress = { type: "channel", name, provider };
addNode(channelAddr);

if (decl.mode === "pairing" || !decl.agent) continue;

const agentDecl = config.agents?.[decl.agent];
const agentType = agentDecl ? resolveAgentMaterialization(provider, agentDecl).resourceType : "agent";
const agentAddr: ResourceAddress = { type: agentType, name: decl.agent, provider };
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk/src/internal/parser/schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -231,6 +231,10 @@ const agentDeliverySchema = z.object({
type: z.enum(["managed", "forward"]),
});

const managedToolConfigSchema = z.object({
enabled_tools: z.array(z.string().min(1)),
});

const sessionGithubRepoResourceSchema = z.object({
type: z.literal("github_repository"),
url: z.string().url(),
Expand DownExpand Up@@ -261,15 +265,17 @@ const agentSchema = z.object({
multiagent: multiagentSchema.optional(),
metadata: z.record(z.string(), z.string()).optional(),
environment_variables: z.record(z.string().min(1), z.string()).optional(),
managed_tool_config: managedToolConfigSchema.optional(),
delivery: z.record(z.string(), agentDeliverySchema).optional(),
});

const channelSchema = z.object({
provider: z.string().optional(),
agent: z.string().min(1),
agent: z.string().min(1).optional(),
identity: z.string().min(1).optional(),
type: z.string().min(1),
name: z.string().trim().min(1).optional(),
mode: z.enum(["fixed", "pairing"]).optional().default("fixed"),
enabled: z.boolean().optional(),
credentials: z.record(z.string(), coerceString).optional(),
options: z.record(z.string(), z.unknown()).optional(),
Expand Down
7 changes: 5 additions & 2 deletions packages/sdk/src/internal/planner/hasher.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,15 +63,18 @@ export function computeReplacementFingerprint(address: ResourceAddress, config:
if (address.type !== "channel") return undefined;
const decl = config.channels?.[address.name];
if (!decl) return undefined;
return contentHash({ channel_type: decl.type, credentials: decl.credentials ?? {} });
return contentHash({ channel_type: decl.type, mode: decl.mode ?? "fixed", credentials: decl.credentials ?? {} });
}

function resolveChannelReferenceIds(
decl: { agent: string; identity?: string },
decl: { agent?: string; identity?: string; mode?: "fixed" | "pairing" },
config: ProjectConfig,
provider: string,
state?: HashStateLookup,
): Record<string, string | null | undefined> {
if (decl.mode === "pairing" || !decl.agent) {
return { mode: "pairing" };
}
const agent = config.agents?.[decl.agent];
const agentType = agent?.delivery?.[provider]?.type === "forward" ? "template" : "agent";
const identity = decl.identity ?? config.defaults?.identity;
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/internal/providers/interface.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,8 +97,8 @@ export interface ResolvedDeploymentRefs {
}

export interface ResolvedChannelRefs {
identity_id: string;
agent_id: string;
identity_id?: string;
agent_id?: string;
}

export interface DeploymentContext {
Expand Down
29 changes: 21 additions & 8 deletions packages/sdk/src/internal/providers/qoder/adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -354,16 +354,21 @@ export class QoderAdapter implements ProviderAdapter {
}
if (type === "channel") {
const channelConfig = (raw.channel_config ?? {}) as Record<string, unknown>;
return compactDeep({
identity_id: raw.identity_id,
template_id: raw.template_id,
const mode = (raw.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
const normalized: Record<string, unknown> = {
identity_resolution: { mode },
channel_type: raw.channel_type,
name: raw.name,
enabled: raw.enabled,
channel_config: {
response_options: channelConfig.response_options ?? {},
},
});
};
if (mode === "fixed") {
normalized.identity_id = raw.identity_id;
normalized.template_id = raw.template_id;
}
return compactDeep(normalized);
}

return compactDeep({
Expand DownExpand Up@@ -564,12 +569,14 @@ export class QoderAdapter implements ProviderAdapter {

async updateChannel(id: string, name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Promise<RemoteResource> {
const current = (await this.forwardClient.get(`/channels/${id}`)) as Record<string, unknown>;
if (current.channel_type !== decl.type) {
const currentMode = (current.identity_resolution as { mode?: string } | undefined)?.mode ?? "fixed";
if (current.channel_type !== decl.type || currentMode !== (decl.mode ?? "fixed")) {
await this.deleteChannel(id);
return this.createChannel(name, decl, refs);
}
const body = this.mapChannel(name, decl, refs);
delete body.channel_type;
delete body.identity_resolution;
const res = (await this.forwardClient.post(`/channels/${id}`, body)) as Record<string, unknown>;
return toRemoteResource(res);
}
Expand All@@ -579,9 +586,8 @@ export class QoderAdapter implements ProviderAdapter {
}

private mapChannel(name: string, decl: ChannelDecl, refs: ResolvedChannelRefs): Record<string, unknown> {
return {
identity_id: refs.identity_id,
template_id: refs.agent_id,
const mode = decl.mode ?? "fixed";
const body: Record<string, unknown> = {
channel_type: decl.type,
name: decl.name ?? name,
enabled: decl.enabled ?? true,
Expand All@@ -594,6 +600,13 @@ export class QoderAdapter implements ProviderAdapter {
},
},
};
if (mode === "pairing") {
body.identity_resolution = { mode: "pairing" };
} else {
body.identity_id = refs.identity_id;
body.template_id = refs.agent_id;
}
return body;
}

private async registerForwardVaults(vaultIds: string[]): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/internal/providers/qoder/mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -493,6 +493,9 @@ export function mapForwardTemplate(
if (projectName) body.metadata = injectMetadata(decl.metadata, projectName, name);
else body.metadata = decl.metadata ?? {};
if (decl.environment_variables) body.environment_variables = decl.environment_variables;
// Sent on create and on update: Forward updates are merge-style, so omitting
// the field would silently keep whatever the Template already had.
if (decl.managed_tool_config) body.managed_tool_config = decl.managed_tool_config;

if (decl.tools) {
body.tools = [
Expand Down
15 changes: 12 additions & 3 deletions packages/sdk/src/internal/types/config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -177,10 +177,17 @@ export interface AgentDecl {
metadata?: Record<string, string>;
/** Qoder runtime environment variables. Forward delivery stores these as Template defaults. */
environment_variables?: Record<string, string>;
/** Provider-side tools the Agent Harness operates itself. Qoder Forward delivery only. */
managed_tool_config?: ManagedToolConfigDecl;
/** Provider-specific remote materialization. Omitted means the existing managed Agent resource. */
delivery?: Record<ProviderName, AgentDeliveryDecl>;
}

export interface ManagedToolConfigDecl {
/** Replaces the provider's enabled managed-tool set; an empty array disables all of them. */
enabled_tools: string[];
}

export interface AgentDeliveryDecl {
type: "managed" | "forward";
}
Expand All@@ -189,12 +196,14 @@ export interface AgentDeliveryDecl {

export interface ChannelDecl {
provider?: ProviderName;
/** Logical Agent name. The provider adapter resolves its materialized remote resource. */
agent: string;
/** Logical Identity name. Falls back to defaults.identity. */
/** Logical Agent name. The provider adapter resolves its materialized remote resource. Required for `fixed` mode; ignored for `pairing` mode. */
agent?: string;
/** Logical Identity name. Falls back to defaults.identity. Required for `fixed` mode; ignored for `pairing` mode. */
identity?: string;
type: string;
name?: string;
/** Identity resolution mode. `fixed` binds the channel to one Identity/Template; `pairing` creates a transport-only channel used by Schedules/Sinks. Defaults to `fixed`. */
mode?: "fixed" | "pairing";
enabled?: boolean;
credentials?: Record<string, string>;
options?: Record<string, unknown>;
Expand Down
Loading