diff --git a/cmd/devsetup/main.go b/cmd/devsetup/main.go index 9c81a7405..f435092c4 100644 --- a/cmd/devsetup/main.go +++ b/cmd/devsetup/main.go @@ -61,10 +61,11 @@ type devConfig struct { // devPluginsConfig mirrors the service's config.PluginConfig; the interval is a // string here because TOML durations are spelled "5m" and the service parses it. type devPluginsConfig struct { - MarketplaceURL string `toml:"marketplace_url"` - MarketplaceBranch string `toml:"marketplace_branch"` - SyncInterval string `toml:"sync_interval"` - SyncEnabled bool `toml:"sync_enabled"` + PersonalAgentTarget string `toml:"personal_agent_target"` + MarketplaceURL string `toml:"marketplace_url"` + MarketplaceBranch string `toml:"marketplace_branch"` + SyncInterval string `toml:"sync_interval"` + SyncEnabled bool `toml:"sync_enabled"` } // defaultPluginsConfig is the backfill for config.toml files predating the @@ -252,6 +253,7 @@ func renderEnv(cfg *devConfig) string { {"GATEWAY_IDAAS_CLIENT_ID", cfg.IDaaS.ClientID}, {"GATEWAY_IDAAS_DISPLAY_NAME_FIELD", cfg.IDaaS.DisplayNameField}, {"CLOUD_PLUGINS_MARKETPLACE_URL", cfg.Plugins.MarketplaceURL}, + {"CLOUD_PLUGINS_PERSONAL_AGENT_TARGET", cfg.Plugins.PersonalAgentTarget}, {"CLOUD_PLUGINS_MARKETPLACE_BRANCH", cfg.Plugins.MarketplaceBranch}, {"CLOUD_PLUGINS_SYNC_INTERVAL", cfg.Plugins.SyncInterval}, {"CLOUD_PLUGINS_SYNC_ENABLED", strconv.FormatBool(cfg.Plugins.SyncEnabled)}, diff --git a/cmd/devsetup/personal_agent_test.go b/cmd/devsetup/personal_agent_test.go new file mode 100644 index 000000000..6c427acab --- /dev/null +++ b/cmd/devsetup/personal_agent_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "strings" + "testing" +) + +// Setup must not discard a rollout target explicitly supplied in the template. +func TestPersonalAgentTargetEnvironment(t *testing.T) { + cfg, err := loadConfig(writeConfig(t, "[database]\ndsn='x'\n[plugins]\npersonal_agent_target='x86_64-unknown-linux-gnu'\nsync_enabled=false\n")) + if err != nil { + t.Fatal(err) + } + if cfg.Plugins.PersonalAgentTarget != "x86_64-unknown-linux-gnu" { + t.Fatal("target was discarded") + } + if !strings.Contains(renderEnv(&cfg), "CLOUD_PLUGINS_PERSONAL_AGENT_TARGET=\"x86_64-unknown-linux-gnu\"\n") { + t.Fatal("target override was not rendered") + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index a6f227467..77049e6a7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -109,6 +109,9 @@ func run() (runErr error) { return e } configureCollaboration(store, cfg.Collaboration.DevelopmentFixtures, log) + if cfg.Plugins.PersonalAgentTarget != "" { + store.PersonalAgentRuntime = &core.PersonalAgentRuntime{Target: cfg.Plugins.PersonalAgentTarget, ContractVersion: 1, Protocols: []string{"openai-completions", "anthropic-messages"}} + } // The B side of the Agent Run seam: Cloud's IssueRun/Thread lifecycle is driven by these five // callbacks, each of which runs inside the control-plane transaction that just persisted the // evidence it reacts to (controller-integration D6). They are the only writer of the B-side diff --git a/config.toml.template b/config.toml.template index 8ef9759cf..6539e01de 100644 --- a/config.toml.template +++ b/config.toml.template @@ -47,6 +47,8 @@ client_secret = "" display_name_field = "userName" [plugins] +# Enable only after Node/Controller and both plugin adapters are deployed; empty disables admission. +personal_agent_target = "" # Plugin marketplace the cloud server keeps a catalog snapshot of. `task setup` turns these # values into CLOUD_PLUGINS_* overrides in .local/dev.env, which configs/config.yaml already # accepts. Leave the section unchanged to use the default marketplace. diff --git a/docs/agent-runs/startup-settlement.en.md b/docs/agent-runs/startup-settlement.en.md index ea92c1da8..355d42a15 100644 --- a/docs/agent-runs/startup-settlement.en.md +++ b/docs/agent-runs/startup-settlement.en.md @@ -46,6 +46,7 @@ atomically** and settles the run in these cases: |---|---| | The user ended the session first, or the run was cancelled | `cancelled` | | The current Node is initialized, connected, with a heartbeat within 30 seconds, and **definitively** lacks the model proxy capability while the run uses a personal model | `failed`, `failureReason = model_proxy_unavailable` | +| Such a Node **definitively** lacks the adapter capability the run's frozen Agent declaration needs (target, contract version or protocol differs) | `failed`, `failureReason = agent_adapter_unavailable` | | The startup window expired | `failed`, `failureReason = agent_start_failed` | Withdrawal and dispatch registration share the same transaction lock, so a registration arriving after the diff --git a/docs/agent-runs/startup-settlement.md b/docs/agent-runs/startup-settlement.md index 7f7fdbcf2..1f22a74e3 100644 --- a/docs/agent-runs/startup-settlement.md +++ b/docs/agent-runs/startup-settlement.md @@ -34,6 +34,7 @@ |---|---| | 用户先结束了会话,或运行被取消 | `cancelled` | | 当前 Node 已初始化、已连接、30 秒内有心跳,并且**明确**不具备模型代理能力,而运行使用个人模型 | `failed`,`failureReason = model_proxy_unavailable` | +| 同样的 Node **明确**不具备运行冻结的 Agent 声明所需的 adapter 能力(target、契约版本或协议不符) | `failed`,`failureReason = agent_adapter_unavailable` | | 启动窗口到期 | `failed`,`failureReason = agent_start_failed` | 撤回与派发登记共用同一把事务锁,所以撤回之后迟到的登记找不到工作项。运行进入 `releasing`,交付状态为 `skipped`,排队中的用户 diff --git a/docs/models/model-access.en.md b/docs/models/model-access.en.md index 3ef4787b3..0519b3edf 100644 --- a/docs/models/model-access.en.md +++ b/docs/models/model-access.en.md @@ -7,6 +7,8 @@ Agent runs they trigger use that model. The key is kept by the platform, and eve are proxied through [model-gateway](model-gateway.en.md): the sandbox running the Agent never receives the original key, only a short-lived token valid for that run alone. +Declared Agent admission is disabled by default. After host rollout, `plugins.personal_agent_target` opts into one exact Linux target. Cloud freezes package/declaration/adapter identities, configuration revision, model metadata and numeric literals. Controller and Node must prove matching target, contract and protocol before execution. Validated Settings JSON travels as text to avoid rounding integers or decimals; runtime authority exists only in mTLS grants and the live plugin startup channel. An incompatible current Node settles as unavailable; disconnected or stale Nodes are not definitive refusals. + This document covers four things on the Cloud side: connections and the default model, key custody, the binding fixed for a run, and short-lived grants for model requests. diff --git a/docs/models/model-access.md b/docs/models/model-access.md index 381e87a7c..0d12b2708 100644 --- a/docs/models/model-access.md +++ b/docs/models/model-access.md @@ -8,6 +8,8 @@ 本文说明 Cloud 一侧的四件事:连接与默认模型、密钥的保管、运行时固定的绑定,以及模型请求的临时授权。 +声明式 Agent 运行默认关闭;部署完成后以 `plugins.personal_agent_target` 指定精确 Linux target。Cloud 冻结包、声明、adapter 版本、配置 revision、模型元数据和数值原文;Controller 与 Node 必须证明同一 target、版本及协议才可开始。Settings 以经过校验的 JSON 文本跨控制边界,避免大整数和高精度小数被中间层舍入;运行 token 只在 mTLS grant 与插件启动内存通道出现。当前 Node 不支持时按能力缺失结算,未连接或过期的 Node 不构成确定性拒绝。 + ## 连接与默认模型 个人模型连接属于**用户本人**,与租户无关:用户加入任何空间之前就可以配置。 diff --git a/docs/operations/configuration.en.md b/docs/operations/configuration.en.md index 1c33675a5..efa02047d 100644 --- a/docs/operations/configuration.en.md +++ b/docs/operations/configuration.en.md @@ -45,6 +45,7 @@ Each process in this repository has its own configuration sources: | `directory.endpoint`, `hw_id`, `environment`, `app_key_file` | Empty | The corporate employee directory; an empty `endpoint` means a public deployment, see [Users, tenants and members](../identity/tenancy.en.md#joining-a-tenant) | | `collaboration.development_fixtures` | `false` | Development fixtures; production must keep it off, see [Issue collaboration](../collaboration/interactions.en.md#ports-issues-own-only-the-consuming-side-contract) | | `plugins.sync_enabled` | — | Whether to sync the plugin marketplace | +| `plugins.personal_agent_target` | Empty | Declared personal-Agent deployment target; empty disables it. Supports `x86_64-unknown-linux-gnu` or `aarch64-unknown-linux-gnu`; enable only after adapter rollout | | `plugins.marketplace_url`, `marketplace_branch`, `sync_interval` | — | Required when sync is on: an `https` Git URL (no query or fragment), a valid branch name, a positive interval | | `plugins.asset_import_enabled`, `asset_import_interval` | —, `30s` | Workflow plugin asset import | | `issue_runs.thread_idle_timeout` | `15m` | The Thread idle window; non-positive values take the default | diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md index 98be74a73..baa5f00fe 100644 --- a/docs/operations/configuration.md +++ b/docs/operations/configuration.md @@ -40,6 +40,7 @@ | `directory.endpoint`、`hw_id`、`environment`、`app_key_file` | 空 | 企业员工目录;`endpoint` 为空即公网部署,见 [用户、租户与成员](../identity/tenancy.md#加入租户) | | `collaboration.development_fixtures` | `false` | 开发夹具,生产必须关闭,见 [Issue 协作](../collaboration/interactions.md#端口issue-只拥有消费侧的契约) | | `plugins.sync_enabled` | — | 是否同步插件市场 | +| `plugins.personal_agent_target` | 空 | 声明式个人 Agent 的部署 target;空值关闭,支持 `x86_64-unknown-linux-gnu` 或 `aarch64-unknown-linux-gnu`,部署完 adapter 再启用 | | `plugins.marketplace_url`、`marketplace_branch`、`sync_interval` | — | 开启同步时必填:`https` Git 地址(不带查询和片段)、合法分支名、正的间隔 | | `plugins.asset_import_enabled`、`asset_import_interval` | —、`30s` | 工作流插件资产导入 | | `issue_runs.thread_idle_timeout` | `15m` | Thread 空闲窗口;非正值取默认 | diff --git a/frontend/src/features/issues/thread/README.en.md b/frontend/src/features/issues/thread/README.en.md index daac8f9e2..704b40a2e 100644 --- a/frontend/src/features/issues/thread/README.en.md +++ b/frontend/src/features/issues/thread/README.en.md @@ -22,7 +22,7 @@ Not owned: creating and listing runs (`useRuns` in `features/issues/api`), SSE c | `run-delivery.tsx` | The Revision delivery line under the Thread (`role=status`, named "Revision 交付"): a registered Revision shows its short commit and whether the run delivered new commits (or, for a resumed run with no new commits, that it reused the resumed work); otherwise `result.deliveryState` explains a skip or failure, and an ended session whose run has not settled shows saving; Revisions are delivered over Git, so no bundle size is shown | | `run-resume.tsx` | The resume line above the Thread (`role=note`, named "续接"): when the run resumed an earlier Revision of the same Issue, it finds that Revision among the Issue's runs and shows its short commit; a fresh run renders nothing | | `run-preparation.tsx` | `RunPreparation` displays server-projected environment, clone, Agent preparation/start phases, the three-attempt clone budget and retry waiting; `snapshot_not_ready` shows as waiting for the Project's first repository sync (a wait, not a failure), and `repository_sync_failed` shows as a failure whatever the stage, with `syncErrorCode` appended only when it is a snake_case code (other values are dropped); `SessionFailure` renders only allowlisted safe codes, with no waiting/idle presentation after failure | -| `thread-failure.ts` | `sessionFailureMessage`: shared finite failure messages for status and durable entries; unknown fields become fixed messages without provider diagnostics | +| `thread-failure.ts` | `sessionFailureMessage`: shared finite failure messages for status and durable entries; each Agent model configuration failure (missing, inconsistent, unsupported protocol, mismatched grant, adapter not applied, Node without the capability) has its own message; unknown fields become fixed messages without provider diagnostics | | `thread-panel.tsx` | `IssueThreadPanel`: run selection, the "Agent 会话" heading, the state badge, the waiting notice and composition | | `*.test.ts(x)` | Pure-function unit tests and MSW integration tests | diff --git a/frontend/src/features/issues/thread/README.md b/frontend/src/features/issues/thread/README.md index 6f6db1221..b3c6526fa 100644 --- a/frontend/src/features/issues/thread/README.md +++ b/frontend/src/features/issues/thread/README.md @@ -23,7 +23,7 @@ Issue 详情页主体的「Agent 会话」:默认读取最近一次 Agent Run | `run-delivery.tsx` | 会话下方的 Revision 交付一行(`role=status`,名称「Revision 交付」):已登记的 Revision 显示短提交与是否交付了新提交(续接后无新提交时说明沿用了续接的成果);Revision 经 Git 交付,不显示 bundle 大小,否则按 `result.deliveryState` 说明跳过或失败,会话已结束但运行未结算时显示保存中 | | `run-resume.tsx` | 会话上方的续接一行(`role=note`,名称「续接」):运行续接了同一 Issue 之前的 Revision 时,从该 Issue 的运行列表中找到它并显示短提交;全新运行不渲染 | | `run-preparation.tsx` | `RunPreparation` 显示服务器提供的环境、克隆、Agent 准备和启动阶段,以及三次克隆预算、重试等待;`snapshot_not_ready` 显示为等待项目首次同步仓库(等待而非失败),`repository_sync_failed` 无论阶段都显示为失败,并附上形如 snake_case 的 `syncErrorCode`(其他值丢弃);`SessionFailure` 仅显示白名单安全错误码,失败不继续显示等待或空闲 | -| `thread-failure.ts` | `sessionFailureMessage`:状态提示与持久失败条目共用的有限错误分类文案;未知字段替换为固定提示,不显示供应商诊断 | +| `thread-failure.ts` | `sessionFailureMessage`:状态提示与持久失败条目共用的有限错误分类文案;Agent 模型配置的几类失败(缺少、不一致、协议不支持、授权不符、adapter 未应用、节点不支持)各有文案;未知字段替换为固定提示,不显示供应商诊断 | | `*.test.ts(x)` | 纯函数单测与 MSW 集成测试 | ## 依赖 diff --git a/frontend/src/features/issues/thread/thread-failure.test.ts b/frontend/src/features/issues/thread/thread-failure.test.ts new file mode 100644 index 000000000..1e456c0d7 --- /dev/null +++ b/frontend/src/features/issues/thread/thread-failure.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { sessionFailureMessage } from './thread-failure' + +describe('sessionFailureMessage', () => { + it('names each Agent model configuration failure instead of a generic one', () => { + expect(sessionFailureMessage('agent_model_protocol_mismatch')).toBe( + 'Agent 插件不支持所选模型连接的协议(agent_model_protocol_mismatch)', + ) + expect(sessionFailureMessage('agent_adapter_unavailable')).toBe( + '执行节点不支持该 Agent 的模型配置(agent_adapter_unavailable)', + ) + }) + + it('replaces an unknown code or provider text with the generic failure', () => { + expect(sessionFailureMessage('401 from https://provider.example')).toBe( + 'Agent 会话失败(agent_failed)', + ) + expect(sessionFailureMessage(undefined)).toBe('Agent 会话失败(agent_failed)') + }) +}) diff --git a/frontend/src/features/issues/thread/thread-failure.ts b/frontend/src/features/issues/thread/thread-failure.ts index 01a2e3824..c024faaa8 100644 --- a/frontend/src/features/issues/thread/thread-failure.ts +++ b/frontend/src/features/issues/thread/thread-failure.ts @@ -9,6 +9,13 @@ const FAILURE_LABELS: Record = { model_access_unavailable: '模型授权不可用', model_access_expired: '模型授权已过期', thread_unavailable: '会话记录不可用', + agent_adapter_unavailable: '执行节点不支持该 Agent 的模型配置', + agent_model_configuration_required: '该 Agent 缺少模型配置', + agent_model_configuration_invalid: 'Agent 模型配置与已安装的插件不一致', + agent_model_protocol_mismatch: 'Agent 插件不支持所选模型连接的协议', + model_grant_mismatch: '模型授权与运行配置不一致', + agent_model_adapter_failed: 'Agent 无法应用模型配置', + agent_model_adapter_not_applied: 'Agent 未确认已应用模型配置', clone_attempts_exhausted: '获取仓库失败,自动重试次数已用尽', repository_sync_failed: '项目仓库同步失败,无法获取仓库', } diff --git a/integration/node_model_adapter_migration_test.go b/integration/node_model_adapter_migration_test.go new file mode 100644 index 000000000..f8acfe862 --- /dev/null +++ b/integration/node_model_adapter_migration_test.go @@ -0,0 +1,20 @@ +package integration + +import "testing" + +// An upgraded legacy schema gains only empty capability evidence; no existing Node is certified. +func TestMigration0060ModelAdapterFreshAndUpgrade(t *testing.T) { + for _, upgrade := range []bool{false, true} { + pool, _ := testSchema(t, "test_model_adapter_") + if upgrade { + applyMigrationsUpTo(t, pool, migrationsBefore(t, "0060_node_model_adapter.sql")) + } + migratedTwice(t, newStoreOnSchema(t, pool)) + var dataType, nullable, defaultValue string + must(t, pool.QueryRow(`SELECT data_type,is_nullable,column_default FROM information_schema.columns + WHERE table_schema=current_schema() AND table_name='node_instances' AND column_name='model_adapter'`).Scan(&dataType, &nullable, &defaultValue)) + if dataType != "jsonb" || nullable != "NO" || defaultValue != "'{}'::jsonb" { + t.Fatalf("upgrade=%v: wrong legacy capability default %s/%s/%s", upgrade, dataType, nullable, defaultValue) + } + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 434e2a65b..26865b577 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -478,11 +478,13 @@ func newRouter(store *core.Store, auth *core.Authenticator, log *zap.Logger, leg // Model capability belongs to the production Controller gRPC registration/record. if strings.HasPrefix(route.Action, "node_") { delete(out, "modelProxy") + delete(out, "modelAdapter") } // Claim and snapshot both return the same snapshot document. if nodes, ok := out["nodes"].([]core.Object); ok { for _, node := range nodes { delete(node, "modelProxy") + delete(node, "modelAdapter") } } } diff --git a/internal/config/README.en.md b/internal/config/README.en.md index 52c9aa5a5..c2f935d92 100644 --- a/internal/config/README.en.md +++ b/internal/config/README.en.md @@ -4,6 +4,8 @@ `Load` uses a local Viper instance to load and validate service configuration, returning `Config`. There is no global mutable configuration. +An empty `plugins.personal_agent_target` disables declared personal-Agent admission. An explicit supported Linux target opts in only after Controller/Node and plugin adapters are deployed; execution still checks the current incarnation's target, contract and protocol. + | File | Responsibility | |---|---| | `config.go` | Main types, defaults, explicit environment bindings and common validation | diff --git a/internal/config/README.md b/internal/config/README.md index 8b61f71bd..13dcd78ba 100644 --- a/internal/config/README.md +++ b/internal/config/README.md @@ -4,6 +4,8 @@ `Load` 用局部 Viper 实例加载服务配置,校验后返回 `Config`;没有全局可变配置。 +`plugins.personal_agent_target` 为空时不启用声明式个人 Agent 运行;可显式指定当前 Node 支持的 Linux target。Controller/Node 与插件 adapter 部署完成后再配置;运行仍校验当前 incarnation 的 target、版本和协议。 + | 文件 | 职责 | |---|---| | `config.go` | 主结构、默认值、显式环境变量绑定和通用校验 | diff --git a/internal/config/config.go b/internal/config/config.go index 20c1d5891..7767fc86b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -93,6 +93,9 @@ type WorkflowConfig struct { // PluginConfig is the cloud-side plugin marketplace configuration. Leaf keys // are bound to CLOUD_PLUGINS_* environment overrides like every other section. type PluginConfig struct { + // PersonalAgentTarget opts into deployed v1 adapters for one exact Linux execution target. + // Empty keeps declared-agent admission disabled during host rollout. + PersonalAgentTarget string `mapstructure:"personal_agent_target"` // MarketplaceURL is the git repository the marketplace catalog is synced from. MarketplaceURL string `mapstructure:"marketplace_url"` // MarketplaceBranch is the branch checked out and fast-forwarded on every sync. @@ -280,6 +283,9 @@ const DefaultAssetImportInterval = 30 * time.Second // marketplace address and branch must be present and well-formed so a typo // fails at startup instead of surfacing as a silently empty catalog. func validatePlugins(p PluginConfig) error { + if p.PersonalAgentTarget != "" && p.PersonalAgentTarget != "x86_64-unknown-linux-gnu" && p.PersonalAgentTarget != "aarch64-unknown-linux-gnu" { + return fmt.Errorf("plugins.personal_agent_target must name a supported Linux target") + } if p.AssetImportEnabled && p.AssetImportInterval <= 0 { return fmt.Errorf("plugins.asset_import_interval must be a positive duration when plugins.asset_import_enabled is true") } diff --git a/internal/config/personal_agent_test.go b/internal/config/personal_agent_test.go new file mode 100644 index 000000000..ef4d9f340 --- /dev/null +++ b/internal/config/personal_agent_test.go @@ -0,0 +1,15 @@ +package config + +import "testing" + +// Rollout is opt-in; a misspelled target must never silently enable another runtime. +func TestPersonalAgentTargetDeploymentGate(t *testing.T) { + for _, target := range []string{"", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"} { + if err := validatePlugins(PluginConfig{PersonalAgentTarget: target}); err != nil { + t.Fatal(err) + } + } + if err := validatePlugins(PluginConfig{PersonalAgentTarget: "linux"}); err == nil { + t.Fatal("unknown execution target accepted") + } +} diff --git a/internal/controlgrpc/agent_configuration_codec.go b/internal/controlgrpc/agent_configuration_codec.go new file mode 100644 index 000000000..caff84400 --- /dev/null +++ b/internal/controlgrpc/agent_configuration_codec.go @@ -0,0 +1,54 @@ +package controlgrpc + +import ( + "encoding/json" + "io" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/wanglongan587/cloud/internal/controlpb" + "github.com/wanglongan587/cloud/internal/core" +) + +// agentConfigurationMessage transports only the immutable private configuration snapshot. +func agentConfigurationMessage(o core.Object) *controlpb.AgentModelConfiguration { + if len(o) == 0 { + return nil + } + model := o.O("model") + return &controlpb.AgentModelConfiguration{ + PluginId: o.S("pluginId"), PluginVersion: o.S("pluginVersion"), Target: o.S("target"), PackageSha256: o.S("packageSha256"), + SchemaVersion: boundedSchemaVersion(o.N("schemaVersion")), DeclarationFingerprint: o.S("declarationFingerprint"), AdapterVersion: boundedSchemaVersion(o.N("adapterVersion")), + ConfigurationRevision: nonnegativeModelValue(o.N("configurationRevision")), SettingsJson: o.S("settingsJson"), + Protocol: o.S("protocol"), Model: &controlpb.AgentBoundModel{Id: model.S("id"), Name: model.S("name"), ContextWindow: nonnegativeModelValue(model.N("contextWindow")), MaxTokens: nonnegativeModelValue(model.N("maxTokens"))}, + } +} + +// agentConfigurationObject retains exact JSON numbers when registering a dispatched execution. +func agentConfigurationObject(p *controlpb.AgentModelConfiguration) (core.Object, error) { + var settings core.Object + decoder := json.NewDecoder(strings.NewReader(p.GetSettingsJson())) + decoder.UseNumber() + revision, revisionOK := receiptSequence(p.GetConfigurationRevision()) + window, windowOK := receiptSequence(p.GetModel().GetContextWindow()) + tokens, tokensOK := receiptSequence(p.GetModel().GetMaxTokens()) + if !revisionOK || !windowOK || !tokensOK || len(p.GetSettingsJson()) > 256*1024 || decoder.Decode(&settings) != nil || settings == nil || decoder.Decode(new(any)) != io.EOF { + return nil, status.Error(codes.InvalidArgument, "invalid_dispatch") + } + return core.Object{ + "pluginId": p.GetPluginId(), "pluginVersion": p.GetPluginVersion(), "target": p.GetTarget(), "packageSha256": p.GetPackageSha256(), + "schemaVersion": int64(p.GetSchemaVersion()), "declarationFingerprint": p.GetDeclarationFingerprint(), "adapterVersion": int64(p.GetAdapterVersion()), + "configurationRevision": revision, "settingsJson": p.GetSettingsJson(), + "protocol": p.GetProtocol(), "model": core.Object{"id": p.GetModel().GetId(), "name": p.GetModel().GetName(), "contextWindow": window, "maxTokens": tokens}, + }, nil +} + +// A corrupt negative durable value becomes unequal wire input, never a wrapped unsigned identity. +func nonnegativeModelValue(value int64) uint64 { + if value < 0 { + return 0 + } + return uint64(value) // #nosec G115 -- negative values are rejected above. +} diff --git a/internal/controlgrpc/agent_configuration_codec_test.go b/internal/controlgrpc/agent_configuration_codec_test.go new file mode 100644 index 000000000..3f00c6d6f --- /dev/null +++ b/internal/controlgrpc/agent_configuration_codec_test.go @@ -0,0 +1,30 @@ +package controlgrpc + +import ( + "reflect" + "testing" + + "github.com/wanglongan587/cloud/internal/core" +) + +// Frozen settings cross both dispatch directions without float64 reinterpretation or credentials. +func TestAgentConfigurationRoundTrip(t *testing.T) { + frozen := core.Object{ + "pluginId": "official/minimal", "pluginVersion": "1.0.0", "target": "x86_64-unknown-linux-gnu", + "packageSha256": "a", "schemaVersion": int64(2), "declarationFingerprint": "b", "adapterVersion": int64(1), + "configurationRevision": int64(4), "settingsJson": "{\"budget\":9007199254740993,\"ratio\":0.123456789012345678901,\"verbose\":false,\"style\":\"literal $(text)\"}", + "protocol": "anthropic-messages", "model": core.Object{"id": "vendor/model", "name": "Model", "contextWindow": int64(128000), "maxTokens": int64(8192)}, + } + got, err := agentConfigurationObject(agentConfigurationMessage(frozen)) + if err != nil || !reflect.DeepEqual(got, frozen) { + t.Fatalf("non-secret frozen input changed: %v", err) + } + if agentConfigurationMessage(core.Object{}) != nil { + t.Fatal("legacy input acquired a declaration") + } + p := agentConfigurationMessage(frozen) + p.SettingsJson += " {}" + if _, err := agentConfigurationObject(p); err == nil { + t.Fatal("extra JSON value accepted") + } +} diff --git a/internal/controlgrpc/execution_codec.go b/internal/controlgrpc/execution_codec.go index c8eb3d58a..120b34ee1 100644 --- a/internal/controlgrpc/execution_codec.go +++ b/internal/controlgrpc/execution_codec.go @@ -145,6 +145,13 @@ func sessionObject(spec *controlpb.AgentSessionSpec) (core.Object, error) { if id := spec.GetModelBindingId(); id != "" { out["modelBindingId"] = id } + if configuration := spec.GetAgentConfiguration(); configuration != nil { + frozen, err := agentConfigurationObject(configuration) + if err != nil { + return nil, err + } + out["agentConfiguration"] = frozen + } if prior := spec.GetPriorRevision(); prior != nil { out["priorRevision"] = priorObject(prior) } @@ -193,6 +200,7 @@ func sessionMessage(o core.Object) *controlpb.AgentSessionSpec { return &controlpb.AgentSessionSpec{ AgentPluginId: o.S("agentPluginId"), AgentPluginVersion: o.S("agentPluginVersion"), CheckoutExecutionId: o.S("checkoutExecutionId"), ModelBindingId: o.S("modelBindingId"), + AgentConfiguration: agentConfigurationMessage(o.O("agentConfiguration")), GitIdentity: &controlpb.GitIdentity{Name: git.S("name"), Email: git.S("email")}, InitialTurn: &controlpb.UserTurn{TurnId: turn.S("turnId"), Content: contentMessages(rows(turn["content"]))}, PriorRevision: priorMessage(o.O("priorRevision")), diff --git a/internal/controlgrpc/nodes.go b/internal/controlgrpc/nodes.go index b445f9e18..d53c9d679 100644 --- a/internal/controlgrpc/nodes.go +++ b/internal/controlgrpc/nodes.go @@ -21,6 +21,9 @@ type nodeService struct { func (s *nodeService) RegisterNode(ctx context.Context, req *controlpb.RegisterNodeRequest) (*controlpb.RegisterNodeResponse, error) { identity := req.GetNode() body := core.Object{"epoch": req.GetEpoch(), "sandboxInstanceId": req.GetSandboxInstanceId(), "generation": req.GetGeneration(), "nodeId": identity.GetNodeId(), "nodeIncarnationId": identity.GetNodeIncarnationId(), "protocolVersion": int64(req.GetProtocolVersion()), "modelProxy": req.GetModelProxy()} + if capability := req.GetModelAdapter(); capability != nil { + body["modelAdapter"] = core.Object{"contractVersion": int64(capability.GetContractVersion()), "target": capability.GetTarget(), "protocols": capability.GetProtocols()} + } out, e := control(ctx, s.store, "report_node_register", "", req.GetSubmissionId(), body) if e != nil { return nil, e @@ -67,10 +70,27 @@ func node(n core.Object) *controlpb.NodeRecord { Id: n.S("id"), SandboxInstanceId: n.S("sandboxInstanceId"), WorkspaceId: n.S("workspaceId"), Connection: controlpb.NodeConnection(enum(controlpb.NodeConnection_value, "NODE_CONNECTION_", n.S("connectionState"))), Initialized: n.B("initialized"), Version: n.N("version"), IdleAdmissionEpoch: optionalInt(n, "idleAdmissionEpoch"), - ModelProxy: n.B("modelProxy"), + ModelProxy: n.B("modelProxy"), + ModelAdapter: modelAdapterMessage(n.O("modelAdapter")), } if n.S("nodeIncarnationId") != "" { out.Identity = &controlpb.NodeIdentity{NodeId: n.S("nodeId"), NodeIncarnationId: n.S("nodeIncarnationId")} } return out } + +// modelAdapterMessage omits unproven legacy state instead of manufacturing host capability. +func modelAdapterMessage(o core.Object) *controlpb.ModelAdapterCapabilities { + if len(o) == 0 { + return nil + } + var protocols []string + if values, ok := o["protocols"].([]any); ok { + for _, value := range values { + if protocol, ok := value.(string); ok { + protocols = append(protocols, protocol) + } + } + } + return &controlpb.ModelAdapterCapabilities{ContractVersion: boundedSchemaVersion(o.N("contractVersion")), Target: o.S("target"), Protocols: protocols} +} diff --git a/internal/controlpb/agent_executions.pb.go b/internal/controlpb/agent_executions.pb.go index be5270e62..406fdd05d 100644 --- a/internal/controlpb/agent_executions.pb.go +++ b/internal/controlpb/agent_executions.pb.go @@ -561,6 +561,8 @@ type AgentSessionSpec struct { ModelBindingId string `protobuf:"bytes,7,opt,name=model_binding_id,json=modelBindingId,proto3" json:"model_binding_id,omitempty"` // Workspaces of this Project, newest first, at most 100, fixed when the work item was released. WorkspaceDirectory []*WorkspaceListing `protobuf:"bytes,8,rep,name=workspace_directory,json=workspaceDirectory,proto3" json:"workspace_directory,omitempty"` + // Exact non-secret configuration frozen at run admission; authority is acquired over mTLS. + AgentConfiguration *AgentModelConfiguration `protobuf:"bytes,9,opt,name=agent_configuration,json=agentConfiguration,proto3" json:"agent_configuration,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -651,6 +653,208 @@ func (x *AgentSessionSpec) GetWorkspaceDirectory() []*WorkspaceListing { return nil } +func (x *AgentSessionSpec) GetAgentConfiguration() *AgentModelConfiguration { + if x != nil { + return x.AgentConfiguration + } + return nil +} + +// Versioned adapter identity, never a runtime token or upstream credential. +type AgentModelConfiguration struct { + state protoimpl.MessageState `protogen:"open.v1"` + PluginId string `protobuf:"bytes,1,opt,name=plugin_id,json=pluginId,proto3" json:"plugin_id,omitempty"` + PluginVersion string `protobuf:"bytes,2,opt,name=plugin_version,json=pluginVersion,proto3" json:"plugin_version,omitempty"` + Target string `protobuf:"bytes,3,opt,name=target,proto3" json:"target,omitempty"` + PackageSha256 string `protobuf:"bytes,4,opt,name=package_sha256,json=packageSha256,proto3" json:"package_sha256,omitempty"` + SchemaVersion uint32 `protobuf:"varint,5,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + DeclarationFingerprint string `protobuf:"bytes,6,opt,name=declaration_fingerprint,json=declarationFingerprint,proto3" json:"declaration_fingerprint,omitempty"` + AdapterVersion uint32 `protobuf:"varint,7,opt,name=adapter_version,json=adapterVersion,proto3" json:"adapter_version,omitempty"` + ConfigurationRevision uint64 `protobuf:"varint,8,opt,name=configuration_revision,json=configurationRevision,proto3" json:"configuration_revision,omitempty"` + // Canonical scalar JSON preserves number lexemes that protobuf Struct would round. + SettingsJson string `protobuf:"bytes,9,opt,name=settings_json,json=settingsJson,proto3" json:"settings_json,omitempty"` + Protocol string `protobuf:"bytes,10,opt,name=protocol,proto3" json:"protocol,omitempty"` + Model *AgentBoundModel `protobuf:"bytes,11,opt,name=model,proto3" json:"model,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentModelConfiguration) Reset() { + *x = AgentModelConfiguration{} + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentModelConfiguration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentModelConfiguration) ProtoMessage() {} + +func (x *AgentModelConfiguration) ProtoReflect() protoreflect.Message { + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentModelConfiguration.ProtoReflect.Descriptor instead. +func (*AgentModelConfiguration) Descriptor() ([]byte, []int) { + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{7} +} + +func (x *AgentModelConfiguration) GetPluginId() string { + if x != nil { + return x.PluginId + } + return "" +} + +func (x *AgentModelConfiguration) GetPluginVersion() string { + if x != nil { + return x.PluginVersion + } + return "" +} + +func (x *AgentModelConfiguration) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *AgentModelConfiguration) GetPackageSha256() string { + if x != nil { + return x.PackageSha256 + } + return "" +} + +func (x *AgentModelConfiguration) GetSchemaVersion() uint32 { + if x != nil { + return x.SchemaVersion + } + return 0 +} + +func (x *AgentModelConfiguration) GetDeclarationFingerprint() string { + if x != nil { + return x.DeclarationFingerprint + } + return "" +} + +func (x *AgentModelConfiguration) GetAdapterVersion() uint32 { + if x != nil { + return x.AdapterVersion + } + return 0 +} + +func (x *AgentModelConfiguration) GetConfigurationRevision() uint64 { + if x != nil { + return x.ConfigurationRevision + } + return 0 +} + +func (x *AgentModelConfiguration) GetSettingsJson() string { + if x != nil { + return x.SettingsJson + } + return "" +} + +func (x *AgentModelConfiguration) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *AgentModelConfiguration) GetModel() *AgentBoundModel { + if x != nil { + return x.Model + } + return nil +} + +// Model catalog metadata frozen in the binding, independent of live personal configuration. +type AgentBoundModel struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + ContextWindow uint64 `protobuf:"varint,3,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` + MaxTokens uint64 `protobuf:"varint,4,opt,name=max_tokens,json=maxTokens,proto3" json:"max_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentBoundModel) Reset() { + *x = AgentBoundModel{} + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentBoundModel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentBoundModel) ProtoMessage() {} + +func (x *AgentBoundModel) ProtoReflect() protoreflect.Message { + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentBoundModel.ProtoReflect.Descriptor instead. +func (*AgentBoundModel) Descriptor() ([]byte, []int) { + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{8} +} + +func (x *AgentBoundModel) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *AgentBoundModel) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AgentBoundModel) GetContextWindow() uint64 { + if x != nil { + return x.ContextWindow + } + return 0 +} + +func (x *AgentBoundModel) GetMaxTokens() uint64 { + if x != nil { + return x.MaxTokens + } + return 0 +} + // The only terminal result of a session execution. It is taken over only after every Thread event // before it, so the Thread's last entry lands before the session is known to have ended. type AgentSessionEnded struct { @@ -664,7 +868,7 @@ type AgentSessionEnded struct { func (x *AgentSessionEnded) Reset() { *x = AgentSessionEnded{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -676,7 +880,7 @@ func (x *AgentSessionEnded) String() string { func (*AgentSessionEnded) ProtoMessage() {} func (x *AgentSessionEnded) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -689,7 +893,7 @@ func (x *AgentSessionEnded) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentSessionEnded.ProtoReflect.Descriptor instead. func (*AgentSessionEnded) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{7} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{9} } func (x *AgentSessionEnded) GetReason() AgentSessionEndReason { @@ -734,7 +938,7 @@ type DeliverRevisionSpec struct { func (x *DeliverRevisionSpec) Reset() { *x = DeliverRevisionSpec{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[8] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -746,7 +950,7 @@ func (x *DeliverRevisionSpec) String() string { func (*DeliverRevisionSpec) ProtoMessage() {} func (x *DeliverRevisionSpec) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[8] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -759,7 +963,7 @@ func (x *DeliverRevisionSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use DeliverRevisionSpec.ProtoReflect.Descriptor instead. func (*DeliverRevisionSpec) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{8} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{10} } func (x *DeliverRevisionSpec) GetSessionExecutionId() string { @@ -838,7 +1042,7 @@ type StoredObject struct { func (x *StoredObject) Reset() { *x = StoredObject{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -850,7 +1054,7 @@ func (x *StoredObject) String() string { func (*StoredObject) ProtoMessage() {} func (x *StoredObject) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -863,7 +1067,7 @@ func (x *StoredObject) ProtoReflect() protoreflect.Message { // Deprecated: Use StoredObject.ProtoReflect.Descriptor instead. func (*StoredObject) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{9} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{11} } func (x *StoredObject) GetKey() string { @@ -900,7 +1104,7 @@ type RevisionDelivered struct { func (x *RevisionDelivered) Reset() { *x = RevisionDelivered{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[10] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -912,7 +1116,7 @@ func (x *RevisionDelivered) String() string { func (*RevisionDelivered) ProtoMessage() {} func (x *RevisionDelivered) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[10] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -925,7 +1129,7 @@ func (x *RevisionDelivered) ProtoReflect() protoreflect.Message { // Deprecated: Use RevisionDelivered.ProtoReflect.Descriptor instead. func (*RevisionDelivered) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{10} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{12} } func (x *RevisionDelivered) GetFinalCommit() string { @@ -971,7 +1175,7 @@ type RevisionUnchanged struct { func (x *RevisionUnchanged) Reset() { *x = RevisionUnchanged{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[11] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -983,7 +1187,7 @@ func (x *RevisionUnchanged) String() string { func (*RevisionUnchanged) ProtoMessage() {} func (x *RevisionUnchanged) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[11] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -996,7 +1200,7 @@ func (x *RevisionUnchanged) ProtoReflect() protoreflect.Message { // Deprecated: Use RevisionUnchanged.ProtoReflect.Descriptor instead. func (*RevisionUnchanged) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{11} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{13} } func (x *RevisionUnchanged) GetFinalCommit() string { @@ -1036,7 +1240,7 @@ type RevisionFailed struct { func (x *RevisionFailed) Reset() { *x = RevisionFailed{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[12] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1048,7 +1252,7 @@ func (x *RevisionFailed) String() string { func (*RevisionFailed) ProtoMessage() {} func (x *RevisionFailed) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[12] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1061,7 +1265,7 @@ func (x *RevisionFailed) ProtoReflect() protoreflect.Message { // Deprecated: Use RevisionFailed.ProtoReflect.Descriptor instead. func (*RevisionFailed) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{12} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{14} } func (x *RevisionFailed) GetReason() RevisionFailureReason { @@ -1091,7 +1295,7 @@ type UploadGrant struct { func (x *UploadGrant) Reset() { *x = UploadGrant{} - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[13] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1103,7 +1307,7 @@ func (x *UploadGrant) String() string { func (*UploadGrant) ProtoMessage() {} func (x *UploadGrant) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[13] + mi := &file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1116,7 +1320,7 @@ func (x *UploadGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadGrant.ProtoReflect.Descriptor instead. func (*UploadGrant) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{13} + return file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP(), []int{15} } func (x *UploadGrant) GetObjectKey() string { @@ -1187,7 +1391,7 @@ const file_ora_cloud_internal_v1_agent_executions_proto_rawDesc = "" + "\fissue_run_id\x18\x05 \x01(\tR\n" + "issueRunId\x12\x1d\n" + "\n" + - "run_status\x18\x06 \x01(\tR\trunStatus\"\xfc\x03\n" + + "run_status\x18\x06 \x01(\tR\trunStatus\"\xdd\x04\n" + "\x10AgentSessionSpec\x12&\n" + "\x0fagent_plugin_id\x18\x01 \x01(\tR\ragentPluginId\x120\n" + "\x14agent_plugin_version\x18\x02 \x01(\tR\x12agentPluginVersion\x122\n" + @@ -1196,7 +1400,27 @@ const file_ora_cloud_internal_v1_agent_executions_proto_rawDesc = "" + "\finitial_turn\x18\x05 \x01(\v2\x1f.ora.cloud.internal.v1.UserTurnR\vinitialTurn\x12K\n" + "\x0eprior_revision\x18\x06 \x01(\v2$.ora.cloud.internal.v1.PriorRevisionR\rpriorRevision\x12(\n" + "\x10model_binding_id\x18\a \x01(\tR\x0emodelBindingId\x12X\n" + - "\x13workspace_directory\x18\b \x03(\v2'.ora.cloud.internal.v1.WorkspaceListingR\x12workspaceDirectory\"\x81\x01\n" + + "\x13workspace_directory\x18\b \x03(\v2'.ora.cloud.internal.v1.WorkspaceListingR\x12workspaceDirectory\x12_\n" + + "\x13agent_configuration\x18\t \x01(\v2..ora.cloud.internal.v1.AgentModelConfigurationR\x12agentConfiguration\"\xdb\x03\n" + + "\x17AgentModelConfiguration\x12\x1b\n" + + "\tplugin_id\x18\x01 \x01(\tR\bpluginId\x12%\n" + + "\x0eplugin_version\x18\x02 \x01(\tR\rpluginVersion\x12\x16\n" + + "\x06target\x18\x03 \x01(\tR\x06target\x12%\n" + + "\x0epackage_sha256\x18\x04 \x01(\tR\rpackageSha256\x12%\n" + + "\x0eschema_version\x18\x05 \x01(\rR\rschemaVersion\x127\n" + + "\x17declaration_fingerprint\x18\x06 \x01(\tR\x16declarationFingerprint\x12'\n" + + "\x0fadapter_version\x18\a \x01(\rR\x0eadapterVersion\x125\n" + + "\x16configuration_revision\x18\b \x01(\x04R\x15configurationRevision\x12#\n" + + "\rsettings_json\x18\t \x01(\tR\fsettingsJson\x12\x1a\n" + + "\bprotocol\x18\n" + + " \x01(\tR\bprotocol\x12<\n" + + "\x05model\x18\v \x01(\v2&.ora.cloud.internal.v1.AgentBoundModelR\x05model\"{\n" + + "\x0fAgentBoundModel\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12%\n" + + "\x0econtext_window\x18\x03 \x01(\x04R\rcontextWindow\x12\x1d\n" + + "\n" + + "max_tokens\x18\x04 \x01(\x04R\tmaxTokens\"\x81\x01\n" + "\x11AgentSessionEnded\x12D\n" + "\x06reason\x18\x01 \x01(\x0e2,.ora.cloud.internal.v1.AgentSessionEndReasonR\x06reason\x12\x1b\n" + "\x06detail\x18\x02 \x01(\tH\x00R\x06detail\x88\x01\x01B\t\n" + @@ -1276,26 +1500,28 @@ func file_ora_cloud_internal_v1_agent_executions_proto_rawDescGZIP() []byte { } var file_ora_cloud_internal_v1_agent_executions_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_ora_cloud_internal_v1_agent_executions_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_ora_cloud_internal_v1_agent_executions_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_ora_cloud_internal_v1_agent_executions_proto_goTypes = []any{ - (AgentSessionEndReason)(0), // 0: ora.cloud.internal.v1.AgentSessionEndReason - (RevisionFailureReason)(0), // 1: ora.cloud.internal.v1.RevisionFailureReason - (*GitIdentity)(nil), // 2: ora.cloud.internal.v1.GitIdentity - (*TextContent)(nil), // 3: ora.cloud.internal.v1.TextContent - (*ContentBlock)(nil), // 4: ora.cloud.internal.v1.ContentBlock - (*UserTurn)(nil), // 5: ora.cloud.internal.v1.UserTurn - (*PriorRevision)(nil), // 6: ora.cloud.internal.v1.PriorRevision - (*WorkspaceListing)(nil), // 7: ora.cloud.internal.v1.WorkspaceListing - (*AgentSessionSpec)(nil), // 8: ora.cloud.internal.v1.AgentSessionSpec - (*AgentSessionEnded)(nil), // 9: ora.cloud.internal.v1.AgentSessionEnded - (*DeliverRevisionSpec)(nil), // 10: ora.cloud.internal.v1.DeliverRevisionSpec - (*StoredObject)(nil), // 11: ora.cloud.internal.v1.StoredObject - (*RevisionDelivered)(nil), // 12: ora.cloud.internal.v1.RevisionDelivered - (*RevisionUnchanged)(nil), // 13: ora.cloud.internal.v1.RevisionUnchanged - (*RevisionFailed)(nil), // 14: ora.cloud.internal.v1.RevisionFailed - (*UploadGrant)(nil), // 15: ora.cloud.internal.v1.UploadGrant - nil, // 16: ora.cloud.internal.v1.UploadGrant.HeadersEntry - (*timestamppb.Timestamp)(nil), // 17: google.protobuf.Timestamp + (AgentSessionEndReason)(0), // 0: ora.cloud.internal.v1.AgentSessionEndReason + (RevisionFailureReason)(0), // 1: ora.cloud.internal.v1.RevisionFailureReason + (*GitIdentity)(nil), // 2: ora.cloud.internal.v1.GitIdentity + (*TextContent)(nil), // 3: ora.cloud.internal.v1.TextContent + (*ContentBlock)(nil), // 4: ora.cloud.internal.v1.ContentBlock + (*UserTurn)(nil), // 5: ora.cloud.internal.v1.UserTurn + (*PriorRevision)(nil), // 6: ora.cloud.internal.v1.PriorRevision + (*WorkspaceListing)(nil), // 7: ora.cloud.internal.v1.WorkspaceListing + (*AgentSessionSpec)(nil), // 8: ora.cloud.internal.v1.AgentSessionSpec + (*AgentModelConfiguration)(nil), // 9: ora.cloud.internal.v1.AgentModelConfiguration + (*AgentBoundModel)(nil), // 10: ora.cloud.internal.v1.AgentBoundModel + (*AgentSessionEnded)(nil), // 11: ora.cloud.internal.v1.AgentSessionEnded + (*DeliverRevisionSpec)(nil), // 12: ora.cloud.internal.v1.DeliverRevisionSpec + (*StoredObject)(nil), // 13: ora.cloud.internal.v1.StoredObject + (*RevisionDelivered)(nil), // 14: ora.cloud.internal.v1.RevisionDelivered + (*RevisionUnchanged)(nil), // 15: ora.cloud.internal.v1.RevisionUnchanged + (*RevisionFailed)(nil), // 16: ora.cloud.internal.v1.RevisionFailed + (*UploadGrant)(nil), // 17: ora.cloud.internal.v1.UploadGrant + nil, // 18: ora.cloud.internal.v1.UploadGrant.HeadersEntry + (*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp } var file_ora_cloud_internal_v1_agent_executions_proto_depIdxs = []int32{ 3, // 0: ora.cloud.internal.v1.ContentBlock.text:type_name -> ora.cloud.internal.v1.TextContent @@ -1304,18 +1530,20 @@ var file_ora_cloud_internal_v1_agent_executions_proto_depIdxs = []int32{ 5, // 3: ora.cloud.internal.v1.AgentSessionSpec.initial_turn:type_name -> ora.cloud.internal.v1.UserTurn 6, // 4: ora.cloud.internal.v1.AgentSessionSpec.prior_revision:type_name -> ora.cloud.internal.v1.PriorRevision 7, // 5: ora.cloud.internal.v1.AgentSessionSpec.workspace_directory:type_name -> ora.cloud.internal.v1.WorkspaceListing - 0, // 6: ora.cloud.internal.v1.AgentSessionEnded.reason:type_name -> ora.cloud.internal.v1.AgentSessionEndReason - 6, // 7: ora.cloud.internal.v1.DeliverRevisionSpec.prior_revision:type_name -> ora.cloud.internal.v1.PriorRevision - 11, // 8: ora.cloud.internal.v1.RevisionDelivered.history:type_name -> ora.cloud.internal.v1.StoredObject - 11, // 9: ora.cloud.internal.v1.RevisionUnchanged.history:type_name -> ora.cloud.internal.v1.StoredObject - 1, // 10: ora.cloud.internal.v1.RevisionFailed.reason:type_name -> ora.cloud.internal.v1.RevisionFailureReason - 16, // 11: ora.cloud.internal.v1.UploadGrant.headers:type_name -> ora.cloud.internal.v1.UploadGrant.HeadersEntry - 17, // 12: ora.cloud.internal.v1.UploadGrant.expires_at:type_name -> google.protobuf.Timestamp - 13, // [13:13] is the sub-list for method output_type - 13, // [13:13] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 9, // 6: ora.cloud.internal.v1.AgentSessionSpec.agent_configuration:type_name -> ora.cloud.internal.v1.AgentModelConfiguration + 10, // 7: ora.cloud.internal.v1.AgentModelConfiguration.model:type_name -> ora.cloud.internal.v1.AgentBoundModel + 0, // 8: ora.cloud.internal.v1.AgentSessionEnded.reason:type_name -> ora.cloud.internal.v1.AgentSessionEndReason + 6, // 9: ora.cloud.internal.v1.DeliverRevisionSpec.prior_revision:type_name -> ora.cloud.internal.v1.PriorRevision + 13, // 10: ora.cloud.internal.v1.RevisionDelivered.history:type_name -> ora.cloud.internal.v1.StoredObject + 13, // 11: ora.cloud.internal.v1.RevisionUnchanged.history:type_name -> ora.cloud.internal.v1.StoredObject + 1, // 12: ora.cloud.internal.v1.RevisionFailed.reason:type_name -> ora.cloud.internal.v1.RevisionFailureReason + 18, // 13: ora.cloud.internal.v1.UploadGrant.headers:type_name -> ora.cloud.internal.v1.UploadGrant.HeadersEntry + 19, // 14: ora.cloud.internal.v1.UploadGrant.expires_at:type_name -> google.protobuf.Timestamp + 15, // [15:15] is the sub-list for method output_type + 15, // [15:15] is the sub-list for method input_type + 15, // [15:15] is the sub-list for extension type_name + 15, // [15:15] is the sub-list for extension extendee + 0, // [0:15] is the sub-list for field type_name } func init() { file_ora_cloud_internal_v1_agent_executions_proto_init() } @@ -1326,14 +1554,14 @@ func file_ora_cloud_internal_v1_agent_executions_proto_init() { file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[2].OneofWrappers = []any{ (*ContentBlock_Text)(nil), } - file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[7].OneofWrappers = []any{} + file_ora_cloud_internal_v1_agent_executions_proto_msgTypes[9].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ora_cloud_internal_v1_agent_executions_proto_rawDesc), len(file_ora_cloud_internal_v1_agent_executions_proto_rawDesc)), NumEnums: 2, - NumMessages: 15, + NumMessages: 17, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/controlpb/nodes.pb.go b/internal/controlpb/nodes.pb.go index d5f3d1e91..05709c486 100644 --- a/internal/controlpb/nodes.pb.go +++ b/internal/controlpb/nodes.pb.go @@ -90,7 +90,8 @@ type NodeRecord struct { // Admission epoch of the latest accepted idle evidence. IdleAdmissionEpoch *int64 `protobuf:"varint,8,opt,name=idle_admission_epoch,json=idleAdmissionEpoch,proto3,oneof" json:"idle_admission_epoch,omitempty"` // Dedicated personal-model proxy access was advertised by this Node's handshake. - ModelProxy bool `protobuf:"varint,9,opt,name=model_proxy,json=modelProxy,proto3" json:"model_proxy,omitempty"` + ModelProxy bool `protobuf:"varint,9,opt,name=model_proxy,json=modelProxy,proto3" json:"model_proxy,omitempty"` + ModelAdapter *ModelAdapterCapabilities `protobuf:"bytes,10,opt,name=model_adapter,json=modelAdapter,proto3" json:"model_adapter,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -188,6 +189,13 @@ func (x *NodeRecord) GetModelProxy() bool { return false } +func (x *NodeRecord) GetModelAdapter() *ModelAdapterCapabilities { + if x != nil { + return x.ModelAdapter + } + return nil +} + type RegisterNodeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SubmissionId string `protobuf:"bytes,1,opt,name=submission_id,json=submissionId,proto3" json:"submission_id,omitempty"` @@ -198,7 +206,8 @@ type RegisterNodeRequest struct { Node *NodeIdentity `protobuf:"bytes,5,opt,name=node,proto3" json:"node,omitempty"` ProtocolVersion uint32 `protobuf:"varint,6,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` // False for older Nodes or Nodes without configured model-access mTLS credentials. - ModelProxy bool `protobuf:"varint,7,opt,name=model_proxy,json=modelProxy,proto3" json:"model_proxy,omitempty"` + ModelProxy bool `protobuf:"varint,7,opt,name=model_proxy,json=modelProxy,proto3" json:"model_proxy,omitempty"` + ModelAdapter *ModelAdapterCapabilities `protobuf:"bytes,8,opt,name=model_adapter,json=modelAdapter,proto3" json:"model_adapter,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -282,6 +291,74 @@ func (x *RegisterNodeRequest) GetModelProxy() bool { return false } +func (x *RegisterNodeRequest) GetModelAdapter() *ModelAdapterCapabilities { + if x != nil { + return x.ModelAdapter + } + return nil +} + +// Exact host capability advertised after startup validation, independent of plugin metadata. +type ModelAdapterCapabilities struct { + state protoimpl.MessageState `protogen:"open.v1"` + ContractVersion uint32 `protobuf:"varint,1,opt,name=contract_version,json=contractVersion,proto3" json:"contract_version,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + Protocols []string `protobuf:"bytes,3,rep,name=protocols,proto3" json:"protocols,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModelAdapterCapabilities) Reset() { + *x = ModelAdapterCapabilities{} + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModelAdapterCapabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModelAdapterCapabilities) ProtoMessage() {} + +func (x *ModelAdapterCapabilities) ProtoReflect() protoreflect.Message { + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModelAdapterCapabilities.ProtoReflect.Descriptor instead. +func (*ModelAdapterCapabilities) Descriptor() ([]byte, []int) { + return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{2} +} + +func (x *ModelAdapterCapabilities) GetContractVersion() uint32 { + if x != nil { + return x.ContractVersion + } + return 0 +} + +func (x *ModelAdapterCapabilities) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *ModelAdapterCapabilities) GetProtocols() []string { + if x != nil { + return x.Protocols + } + return nil +} + type RegisterNodeResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Node *NodeRecord `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` @@ -291,7 +368,7 @@ type RegisterNodeResponse struct { func (x *RegisterNodeResponse) Reset() { *x = RegisterNodeResponse{} - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[2] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -303,7 +380,7 @@ func (x *RegisterNodeResponse) String() string { func (*RegisterNodeResponse) ProtoMessage() {} func (x *RegisterNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[2] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -316,7 +393,7 @@ func (x *RegisterNodeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisterNodeResponse.ProtoReflect.Descriptor instead. func (*RegisterNodeResponse) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{2} + return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{3} } func (x *RegisterNodeResponse) GetNode() *NodeRecord { @@ -341,7 +418,7 @@ type ReportNodeStatusRequest struct { func (x *ReportNodeStatusRequest) Reset() { *x = ReportNodeStatusRequest{} - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[3] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -353,7 +430,7 @@ func (x *ReportNodeStatusRequest) String() string { func (*ReportNodeStatusRequest) ProtoMessage() {} func (x *ReportNodeStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[3] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -366,7 +443,7 @@ func (x *ReportNodeStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportNodeStatusRequest.ProtoReflect.Descriptor instead. func (*ReportNodeStatusRequest) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{3} + return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{4} } func (x *ReportNodeStatusRequest) GetSubmissionId() string { @@ -420,7 +497,7 @@ type ReportNodeStatusResponse struct { func (x *ReportNodeStatusResponse) Reset() { *x = ReportNodeStatusResponse{} - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[4] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -432,7 +509,7 @@ func (x *ReportNodeStatusResponse) String() string { func (*ReportNodeStatusResponse) ProtoMessage() {} func (x *ReportNodeStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[4] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -445,7 +522,7 @@ func (x *ReportNodeStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportNodeStatusResponse.ProtoReflect.Descriptor instead. func (*ReportNodeStatusResponse) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{4} + return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{5} } func (x *ReportNodeStatusResponse) GetNode() *NodeRecord { @@ -467,7 +544,7 @@ type EndNodeRequest struct { func (x *EndNodeRequest) Reset() { *x = EndNodeRequest{} - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[5] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -479,7 +556,7 @@ func (x *EndNodeRequest) String() string { func (*EndNodeRequest) ProtoMessage() {} func (x *EndNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[5] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -492,7 +569,7 @@ func (x *EndNodeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EndNodeRequest.ProtoReflect.Descriptor instead. func (*EndNodeRequest) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{5} + return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{6} } func (x *EndNodeRequest) GetSubmissionId() string { @@ -532,7 +609,7 @@ type EndNodeResponse struct { func (x *EndNodeResponse) Reset() { *x = EndNodeResponse{} - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[6] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -544,7 +621,7 @@ func (x *EndNodeResponse) String() string { func (*EndNodeResponse) ProtoMessage() {} func (x *EndNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[6] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -557,7 +634,7 @@ func (x *EndNodeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EndNodeResponse.ProtoReflect.Descriptor instead. func (*EndNodeResponse) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{6} + return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{7} } func (x *EndNodeResponse) GetNode() *NodeRecord { @@ -582,7 +659,7 @@ type ReportNodeIdleRequest struct { func (x *ReportNodeIdleRequest) Reset() { *x = ReportNodeIdleRequest{} - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[7] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -594,7 +671,7 @@ func (x *ReportNodeIdleRequest) String() string { func (*ReportNodeIdleRequest) ProtoMessage() {} func (x *ReportNodeIdleRequest) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[7] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -607,7 +684,7 @@ func (x *ReportNodeIdleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportNodeIdleRequest.ProtoReflect.Descriptor instead. func (*ReportNodeIdleRequest) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{7} + return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{8} } func (x *ReportNodeIdleRequest) GetSubmissionId() string { @@ -671,7 +748,7 @@ type ReportNodeIdleResponse struct { func (x *ReportNodeIdleResponse) Reset() { *x = ReportNodeIdleResponse{} - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[8] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -683,7 +760,7 @@ func (x *ReportNodeIdleResponse) String() string { func (*ReportNodeIdleResponse) ProtoMessage() {} func (x *ReportNodeIdleResponse) ProtoReflect() protoreflect.Message { - mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[8] + mi := &file_ora_cloud_internal_v1_nodes_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -696,7 +773,7 @@ func (x *ReportNodeIdleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportNodeIdleResponse.ProtoReflect.Descriptor instead. func (*ReportNodeIdleResponse) Descriptor() ([]byte, []int) { - return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{8} + return file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP(), []int{9} } func (x *ReportNodeIdleResponse) GetAccepted() bool { @@ -717,7 +794,7 @@ var File_ora_cloud_internal_v1_nodes_proto protoreflect.FileDescriptor const file_ora_cloud_internal_v1_nodes_proto_rawDesc = "" + "\n" + - "!ora/cloud/internal/v1/nodes.proto\x12\x15ora.cloud.internal.v1\x1a&ora/cloud/internal/v1/executions.proto\"\xa4\x03\n" + + "!ora/cloud/internal/v1/nodes.proto\x12\x15ora.cloud.internal.v1\x1a&ora/cloud/internal/v1/executions.proto\"\xfa\x03\n" + "\n" + "NodeRecord\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12.\n" + @@ -731,8 +808,10 @@ const file_ora_cloud_internal_v1_nodes_proto_rawDesc = "" + "\aversion\x18\a \x01(\x03R\aversion\x125\n" + "\x14idle_admission_epoch\x18\b \x01(\x03H\x00R\x12idleAdmissionEpoch\x88\x01\x01\x12\x1f\n" + "\vmodel_proxy\x18\t \x01(\bR\n" + - "modelProxyB\x17\n" + - "\x15_idle_admission_epoch\"\xa5\x02\n" + + "modelProxy\x12T\n" + + "\rmodel_adapter\x18\n" + + " \x01(\v2/.ora.cloud.internal.v1.ModelAdapterCapabilitiesR\fmodelAdapterB\x17\n" + + "\x15_idle_admission_epoch\"\xfb\x02\n" + "\x13RegisterNodeRequest\x12#\n" + "\rsubmission_id\x18\x01 \x01(\tR\fsubmissionId\x12\x14\n" + "\x05epoch\x18\x02 \x01(\x03R\x05epoch\x12.\n" + @@ -743,7 +822,12 @@ const file_ora_cloud_internal_v1_nodes_proto_rawDesc = "" + "\x04node\x18\x05 \x01(\v2#.ora.cloud.internal.v1.NodeIdentityR\x04node\x12)\n" + "\x10protocol_version\x18\x06 \x01(\rR\x0fprotocolVersion\x12\x1f\n" + "\vmodel_proxy\x18\a \x01(\bR\n" + - "modelProxy\"M\n" + + "modelProxy\x12T\n" + + "\rmodel_adapter\x18\b \x01(\v2/.ora.cloud.internal.v1.ModelAdapterCapabilitiesR\fmodelAdapter\"{\n" + + "\x18ModelAdapterCapabilities\x12)\n" + + "\x10contract_version\x18\x01 \x01(\rR\x0fcontractVersion\x12\x16\n" + + "\x06target\x18\x02 \x01(\tR\x06target\x12\x1c\n" + + "\tprotocols\x18\x03 \x03(\tR\tprotocols\"M\n" + "\x14RegisterNodeResponse\x125\n" + "\x04node\x18\x01 \x01(\v2!.ora.cloud.internal.v1.NodeRecordR\x04node\"\x81\x02\n" + "\x17ReportNodeStatusRequest\x12#\n" + @@ -802,42 +886,45 @@ func file_ora_cloud_internal_v1_nodes_proto_rawDescGZIP() []byte { } var file_ora_cloud_internal_v1_nodes_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_ora_cloud_internal_v1_nodes_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_ora_cloud_internal_v1_nodes_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_ora_cloud_internal_v1_nodes_proto_goTypes = []any{ (NodeConnection)(0), // 0: ora.cloud.internal.v1.NodeConnection (*NodeRecord)(nil), // 1: ora.cloud.internal.v1.NodeRecord (*RegisterNodeRequest)(nil), // 2: ora.cloud.internal.v1.RegisterNodeRequest - (*RegisterNodeResponse)(nil), // 3: ora.cloud.internal.v1.RegisterNodeResponse - (*ReportNodeStatusRequest)(nil), // 4: ora.cloud.internal.v1.ReportNodeStatusRequest - (*ReportNodeStatusResponse)(nil), // 5: ora.cloud.internal.v1.ReportNodeStatusResponse - (*EndNodeRequest)(nil), // 6: ora.cloud.internal.v1.EndNodeRequest - (*EndNodeResponse)(nil), // 7: ora.cloud.internal.v1.EndNodeResponse - (*ReportNodeIdleRequest)(nil), // 8: ora.cloud.internal.v1.ReportNodeIdleRequest - (*ReportNodeIdleResponse)(nil), // 9: ora.cloud.internal.v1.ReportNodeIdleResponse - (*NodeIdentity)(nil), // 10: ora.cloud.internal.v1.NodeIdentity + (*ModelAdapterCapabilities)(nil), // 3: ora.cloud.internal.v1.ModelAdapterCapabilities + (*RegisterNodeResponse)(nil), // 4: ora.cloud.internal.v1.RegisterNodeResponse + (*ReportNodeStatusRequest)(nil), // 5: ora.cloud.internal.v1.ReportNodeStatusRequest + (*ReportNodeStatusResponse)(nil), // 6: ora.cloud.internal.v1.ReportNodeStatusResponse + (*EndNodeRequest)(nil), // 7: ora.cloud.internal.v1.EndNodeRequest + (*EndNodeResponse)(nil), // 8: ora.cloud.internal.v1.EndNodeResponse + (*ReportNodeIdleRequest)(nil), // 9: ora.cloud.internal.v1.ReportNodeIdleRequest + (*ReportNodeIdleResponse)(nil), // 10: ora.cloud.internal.v1.ReportNodeIdleResponse + (*NodeIdentity)(nil), // 11: ora.cloud.internal.v1.NodeIdentity } var file_ora_cloud_internal_v1_nodes_proto_depIdxs = []int32{ - 10, // 0: ora.cloud.internal.v1.NodeRecord.identity:type_name -> ora.cloud.internal.v1.NodeIdentity + 11, // 0: ora.cloud.internal.v1.NodeRecord.identity:type_name -> ora.cloud.internal.v1.NodeIdentity 0, // 1: ora.cloud.internal.v1.NodeRecord.connection:type_name -> ora.cloud.internal.v1.NodeConnection - 10, // 2: ora.cloud.internal.v1.RegisterNodeRequest.node:type_name -> ora.cloud.internal.v1.NodeIdentity - 1, // 3: ora.cloud.internal.v1.RegisterNodeResponse.node:type_name -> ora.cloud.internal.v1.NodeRecord - 0, // 4: ora.cloud.internal.v1.ReportNodeStatusRequest.connection:type_name -> ora.cloud.internal.v1.NodeConnection - 1, // 5: ora.cloud.internal.v1.ReportNodeStatusResponse.node:type_name -> ora.cloud.internal.v1.NodeRecord - 1, // 6: ora.cloud.internal.v1.EndNodeResponse.node:type_name -> ora.cloud.internal.v1.NodeRecord - 1, // 7: ora.cloud.internal.v1.ReportNodeIdleResponse.node:type_name -> ora.cloud.internal.v1.NodeRecord - 2, // 8: ora.cloud.internal.v1.NodeReportService.RegisterNode:input_type -> ora.cloud.internal.v1.RegisterNodeRequest - 4, // 9: ora.cloud.internal.v1.NodeReportService.ReportNodeStatus:input_type -> ora.cloud.internal.v1.ReportNodeStatusRequest - 6, // 10: ora.cloud.internal.v1.NodeReportService.EndNode:input_type -> ora.cloud.internal.v1.EndNodeRequest - 8, // 11: ora.cloud.internal.v1.NodeReportService.ReportNodeIdle:input_type -> ora.cloud.internal.v1.ReportNodeIdleRequest - 3, // 12: ora.cloud.internal.v1.NodeReportService.RegisterNode:output_type -> ora.cloud.internal.v1.RegisterNodeResponse - 5, // 13: ora.cloud.internal.v1.NodeReportService.ReportNodeStatus:output_type -> ora.cloud.internal.v1.ReportNodeStatusResponse - 7, // 14: ora.cloud.internal.v1.NodeReportService.EndNode:output_type -> ora.cloud.internal.v1.EndNodeResponse - 9, // 15: ora.cloud.internal.v1.NodeReportService.ReportNodeIdle:output_type -> ora.cloud.internal.v1.ReportNodeIdleResponse - 12, // [12:16] is the sub-list for method output_type - 8, // [8:12] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 3, // 2: ora.cloud.internal.v1.NodeRecord.model_adapter:type_name -> ora.cloud.internal.v1.ModelAdapterCapabilities + 11, // 3: ora.cloud.internal.v1.RegisterNodeRequest.node:type_name -> ora.cloud.internal.v1.NodeIdentity + 3, // 4: ora.cloud.internal.v1.RegisterNodeRequest.model_adapter:type_name -> ora.cloud.internal.v1.ModelAdapterCapabilities + 1, // 5: ora.cloud.internal.v1.RegisterNodeResponse.node:type_name -> ora.cloud.internal.v1.NodeRecord + 0, // 6: ora.cloud.internal.v1.ReportNodeStatusRequest.connection:type_name -> ora.cloud.internal.v1.NodeConnection + 1, // 7: ora.cloud.internal.v1.ReportNodeStatusResponse.node:type_name -> ora.cloud.internal.v1.NodeRecord + 1, // 8: ora.cloud.internal.v1.EndNodeResponse.node:type_name -> ora.cloud.internal.v1.NodeRecord + 1, // 9: ora.cloud.internal.v1.ReportNodeIdleResponse.node:type_name -> ora.cloud.internal.v1.NodeRecord + 2, // 10: ora.cloud.internal.v1.NodeReportService.RegisterNode:input_type -> ora.cloud.internal.v1.RegisterNodeRequest + 5, // 11: ora.cloud.internal.v1.NodeReportService.ReportNodeStatus:input_type -> ora.cloud.internal.v1.ReportNodeStatusRequest + 7, // 12: ora.cloud.internal.v1.NodeReportService.EndNode:input_type -> ora.cloud.internal.v1.EndNodeRequest + 9, // 13: ora.cloud.internal.v1.NodeReportService.ReportNodeIdle:input_type -> ora.cloud.internal.v1.ReportNodeIdleRequest + 4, // 14: ora.cloud.internal.v1.NodeReportService.RegisterNode:output_type -> ora.cloud.internal.v1.RegisterNodeResponse + 6, // 15: ora.cloud.internal.v1.NodeReportService.ReportNodeStatus:output_type -> ora.cloud.internal.v1.ReportNodeStatusResponse + 8, // 16: ora.cloud.internal.v1.NodeReportService.EndNode:output_type -> ora.cloud.internal.v1.EndNodeResponse + 10, // 17: ora.cloud.internal.v1.NodeReportService.ReportNodeIdle:output_type -> ora.cloud.internal.v1.ReportNodeIdleResponse + 14, // [14:18] is the sub-list for method output_type + 10, // [10:14] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_ora_cloud_internal_v1_nodes_proto_init() } @@ -847,14 +934,14 @@ func file_ora_cloud_internal_v1_nodes_proto_init() { } file_ora_cloud_internal_v1_executions_proto_init() file_ora_cloud_internal_v1_nodes_proto_msgTypes[0].OneofWrappers = []any{} - file_ora_cloud_internal_v1_nodes_proto_msgTypes[8].OneofWrappers = []any{} + file_ora_cloud_internal_v1_nodes_proto_msgTypes[9].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ora_cloud_internal_v1_nodes_proto_rawDesc), len(file_ora_cloud_internal_v1_nodes_proto_rawDesc)), NumEnums: 1, - NumMessages: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/core/agent_adapter_capability.go b/internal/core/agent_adapter_capability.go new file mode 100644 index 000000000..a279ef60c --- /dev/null +++ b/internal/core/agent_adapter_capability.go @@ -0,0 +1,66 @@ +package core + +import "slices" + +// validAgentAdapterCapability accepts only the deployed v1 surface; absence is honest legacy state. +func validAgentAdapterCapability(capability Object, proxy bool) bool { + if len(capability) == 0 { + return true + } + if !proxy || capability.N("contractVersion") != 1 || !validAgentTarget(capability.S("target")) { + return false + } + protocols := adapterProtocols(capability["protocols"]) + if len(protocols) == 0 || len(protocols) > 2 { + return false + } + seen := map[string]bool{} + for _, protocol := range protocols { + if (protocol != "openai-completions" && protocol != "anthropic-messages") || seen[protocol] { + return false + } + seen[protocol] = true + } + return true +} + +// validAgentTarget names the Linux targets supported by the current Node execution path. +func validAgentTarget(target string) bool { + return target == "x86_64-unknown-linux-gnu" || target == "aarch64-unknown-linux-gnu" +} + +// declaredAgentCapable rechecks the live incarnation against immutable admission requirements. +func declaredAgentCapable(node, configuration Object) bool { + if len(configuration) == 0 { + return true + } + capability := node.O("modelAdapter") + return node.B("modelProxy") && capability.N("contractVersion") == configuration.N("adapterVersion") && + capability.S("target") == configuration.S("target") && slices.Contains(adapterProtocols(capability["protocols"]), configuration.S("protocol")) +} + +// Adapter reports and PostgreSQL JSON decoding use different concrete slice representations. +func adapterProtocols(value any) []string { + if protocols, ok := value.([]string); ok { + return protocols + } + values, ok := value.([]any) + if !ok { + return nil + } + protocols := make([]string, 0, len(values)) + for _, value := range values { + protocol, ok := value.(string) + if !ok { + return nil + } + protocols = append(protocols, protocol) + } + return protocols +} + +// SQL's preselection mirrors declaredAgentCapable so incompatible incarnations are settled promptly. +const declaredAdapterMismatchSQL = `EXISTS (SELECT 1 FROM run_agent_configurations c JOIN run_model_bindings b ON b.run_id=c.run_id + WHERE c.run_id=ir.id AND (COALESCE(n.model_adapter->>'target','')<>c.target + OR COALESCE(n.model_adapter->>'contractVersion','0')<>c.adapter_contract_version::text + OR NOT COALESCE(n.model_adapter->'protocols','[]'::jsonb) ? b.protocol))` diff --git a/internal/core/agent_adapter_capability_test.go b/internal/core/agent_adapter_capability_test.go new file mode 100644 index 000000000..338666b82 --- /dev/null +++ b/internal/core/agent_adapter_capability_test.go @@ -0,0 +1,33 @@ +package core + +import "testing" + +// Metadata is not capability: every exact execution requirement must match the live incarnation. +func TestDeclaredAgentCapabilityMatrix(t *testing.T) { + configuration := Object{"target": "x86_64-unknown-linux-gnu", "adapterVersion": int64(1), "protocol": "anthropic-messages"} + valid := Object{"contractVersion": int64(1), "target": "x86_64-unknown-linux-gnu", "protocols": []string{"openai-completions", "anthropic-messages"}} + for _, test := range []struct { + name string + capability Object + proxy, valid, capable bool + }{ + {"absent", Object{}, true, true, false}, + {"exact", valid, true, true, true}, + {"no mTLS", valid, false, false, false}, + {"other target", Object{"contractVersion": int64(1), "target": "aarch64-unknown-linux-gnu", "protocols": []string{"anthropic-messages"}}, true, true, false}, + {"other protocol", Object{"contractVersion": int64(1), "target": "x86_64-unknown-linux-gnu", "protocols": []string{"openai-completions"}}, true, true, false}, + {"unknown version", Object{"contractVersion": int64(2), "target": "x86_64-unknown-linux-gnu", "protocols": []string{"anthropic-messages"}}, true, false, false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := validAgentAdapterCapability(test.capability, test.proxy); got != test.valid { + t.Fatalf("registration validity=%v", got) + } + if got := declaredAgentCapable(Object{"modelProxy": test.proxy, "modelAdapter": test.capability}, configuration); got != test.capable { + t.Fatalf("dispatch capability=%v", got) + } + }) + } + if !declaredAgentCapable(Object{}, Object{}) { + t.Fatal("legacy input acquired a declared-adapter requirement") + } +} diff --git a/internal/core/agent_run_failure.go b/internal/core/agent_run_failure.go index 6b4a86558..b7788f42a 100644 --- a/internal/core/agent_run_failure.go +++ b/internal/core/agent_run_failure.go @@ -12,11 +12,18 @@ func appendSessionFailure(t *transaction, run Object, detail string) { // publicSessionFailureCode accepts only bounded startup or Node classifications. Raw ACP/provider // diagnostics are never a public failureReason, even when supplied by a registered Node. +// +// The plugin-owned adapter codes stay distinct because ADR D2 tells an incomplete configuration, an +// incompatible one, a refused grant and a failed preparation apart; collapsing them into +// `agent_failed` would leave the user no way to know which one to fix. func publicSessionFailureCode(detail string) string { switch detail { case "agent_turn_failed", "agent_stream_closed", "agent_unavailable", "agent_start_failed", "agent_authentication_required", "model_proxy_unavailable", "model_access_unavailable", - "model_access_expired", "thread_unavailable": + "model_access_expired", "thread_unavailable", + "agent_adapter_unavailable", "agent_model_configuration_required", + "agent_model_configuration_invalid", "agent_model_protocol_mismatch", "model_grant_mismatch", + "agent_model_adapter_failed", "agent_model_adapter_not_applied": return detail default: return "agent_failed" diff --git a/internal/core/agent_run_failure_test.go b/internal/core/agent_run_failure_test.go new file mode 100644 index 000000000..c3959408a --- /dev/null +++ b/internal/core/agent_run_failure_test.go @@ -0,0 +1,24 @@ +package core + +import "testing" + +// Only bounded classifications become public; each adapter failure keeps its own code (ADR D2). +func TestPublicSessionFailureCode(t *testing.T) { + for detail, want := range map[string]string{ + "agent_turn_failed": "agent_turn_failed", + "agent_adapter_unavailable": "agent_adapter_unavailable", + "agent_model_configuration_required": "agent_model_configuration_required", + "agent_model_configuration_invalid": "agent_model_configuration_invalid", + "agent_model_protocol_mismatch": "agent_model_protocol_mismatch", + "model_grant_mismatch": "model_grant_mismatch", + "agent_model_adapter_failed": "agent_model_adapter_failed", + "agent_model_adapter_not_applied": "agent_model_adapter_not_applied", + "": "agent_failed", + "401 from https://provider.example: invalid x-api-key": "agent_failed", + "agent_model_something_new": "agent_failed", + } { + if got := publicSessionFailureCode(detail); got != want { + t.Errorf("publicSessionFailureCode(%q) = %q, want %q", detail, got, want) + } + } +} diff --git a/internal/core/agent_run_session_start.go b/internal/core/agent_run_session_start.go index 9599e7d41..ccfde78fd 100644 --- a/internal/core/agent_run_session_start.go +++ b/internal/core/agent_run_session_start.go @@ -158,6 +158,20 @@ func (s *Store) sessionStartSpec(t *transaction, o, snap Object, turnID, content if bindingID := snap.S("modelBindingId"); bindingID != "" { spec["modelBindingId"] = bindingID } + if frozen := t.one("SELECT *,settings::text AS settings_json FROM run_agent_configurations WHERE run_id=$1", runID); frozen != nil { + binding := t.one("SELECT protocol,model FROM run_model_bindings WHERE id=$1 AND run_id=$2", snap.S("modelBindingId"), runID) + if binding == nil { + return nil, fmt.Errorf("session start: frozen agent has no model binding") + } + spec["agentConfiguration"] = Object{ + "pluginId": frozen.S("pluginId"), "pluginVersion": frozen.S("pluginVersion"), + "target": frozen.S("target"), "packageSha256": frozen.S("packageSha256"), + "schemaVersion": frozen.N("declarationSchemaVersion"), "declarationFingerprint": frozen.S("declarationFingerprint"), + "adapterVersion": frozen.N("adapterContractVersion"), "configurationRevision": frozen.N("configurationRevision"), + "settingsJson": frozen.S("settingsJson"), + "protocol": binding.S("protocol"), "model": binding.O("model"), + } + } if prior != nil { spec["priorRevision"] = prior } @@ -282,7 +296,7 @@ func (s *Store) scanStartingAgentRuns(ctx context.Context, limit int) ([]string, JOIN workspaces w ON w.id=n.workspace_id AND w.runtime_generation=si.generation WHERE n.workspace_id=ir.workspace_id AND n.initialized AND n.connection_state='connected' AND n.ended_at IS NULL AND si.terminated_at IS NULL - AND n.last_seen_at>clock_timestamp()-interval '30 seconds' AND NOT n.model_proxy))))))) + AND n.last_seen_at>clock_timestamp()-interval '30 seconds' AND (NOT n.model_proxy OR `+declaredAdapterMismatchSQL+`)))))))) ORDER BY ir.created_at, ir.id LIMIT $1`, limit) if err != nil { return nil, err diff --git a/internal/core/agent_run_startup.go b/internal/core/agent_run_startup.go index 4a1a401fb..837ddb086 100644 --- a/internal/core/agent_run_startup.go +++ b/internal/core/agent_run_startup.go @@ -26,6 +26,10 @@ func (s *Store) reconcileAgentStartup(t *transaction, runID string) bool { s.releaseUndispatchedAgentStartup(t, run, "failed", "model_proxy_unavailable", "capability_rejected") return true } + if evidence.adapterUnavailable { + s.releaseUndispatchedAgentStartup(t, run, "failed", "agent_adapter_unavailable", "capability_rejected") + return true + } if evidence.expired { s.releaseUndispatchedAgentStartup(t, run, "failed", "agent_start_failed", "startup_timeout") return true diff --git a/internal/core/declared_agent_startup_db_test.go b/internal/core/declared_agent_startup_db_test.go new file mode 100644 index 000000000..5e303d915 --- /dev/null +++ b/internal/core/declared_agent_startup_db_test.go @@ -0,0 +1,79 @@ +package core + +import ( + "context" + "strings" + "testing" +) + +// The real freeze/create path plus a completed clone supplies a dispatch-ready baseline. +func declaredStartupScene(t *testing.T) (personalAgentScene, string) { + t.Helper() + scene := newPersonalAgentScene(t) + if _, err := scene.request("PUT", "", newID(), scene.body(0)); err != nil { + t.Fatal(err) + } + scene.store.PersonalAgentRuntime = &PersonalAgentRuntime{Target: "x86_64-unknown-linux-gnu", ContractVersion: 1, Protocols: []string{"anthropic-messages"}} + result, err := scene.store.transact(context.Background(), func(tx *transaction) Object { + input := Object{"agentPluginId": claudeAgentCLI, "agentPluginVersion": "1.0.0"} + bindRunModel(tx, scene.seed.run, scene.uid, input) + created := createRunWorkspace(tx, scene.seed.run) + workspace, operation := created.O("workspace").S("id"), created.O("operation").S("id") + tx.exec("UPDATE operations SET state='succeeded',step='done',version=version+1 WHERE id=$1", operation) + finishRuntimeMaintenance(tx, operation) + tx.exec("UPDATE workspaces SET observed_state='ready',admission_open=true,base_commit_id=$2 WHERE id=$1", workspace, baseCommit) + tx.exec(`INSERT INTO clone_executions(execution_id,operation_id,node_id,node_operation_id,workspace_id,input,result,dispatched_epoch) + VALUES($1,$2,'node-checkout','op-checkout',$3,'{"kind":"clone"}',jsonb_build_object('outcome','clone_ready','commit',$4::text),1)`, "clone-"+workspace, operation, workspace, baseCommit) + tx.exec("UPDATE issue_runs SET phase='starting',status='dispatched',workspace_id=$2 WHERE id=$1", scene.seed.run, workspace) + return Object{"workspaceId": workspace} + }) + if err != nil { + t.Fatal(err) + } + return scene, result.S("workspaceId") +} + +// Dispatch reads private frozen facts, never the latest personal options or a public prompt field. +func TestDeclaredAgentSessionSnapshotIsPrivateAndImmutable(t *testing.T) { + scene, _ := declaredStartupScene(t) + body := scene.body(1) + body["settings"] = Object{"style": "edited-after-admission"} + if _, err := scene.request("PUT", "", newID(), body); err != nil { + t.Fatal(err) + } + spec, err := scene.store.transact(context.Background(), func(tx *transaction) Object { + run := tx.one("SELECT * FROM issue_runs WHERE id=$1", scene.seed.run) + spec, err := scene.store.sessionStartSpec(tx, run, run.O("input"), "turn-1", "task", nil) + if err != nil { + t.Fatal(err) + } + return spec + }) + if err != nil { + t.Fatal(err) + } + configuration := spec.O("agentConfiguration") + if configuration.N("configurationRevision") != 1 || configuration.S("protocol") != "anthropic-messages" || configuration.O("model").S("id") != "vendor/model-v1" || !strings.Contains(configuration.S("settingsJson"), "concise") || strings.Contains(jsonText(spec), "edited-after-admission") { + t.Fatalf("frozen input changed: %v", configuration) + } + if strings.Contains(jsonText(spec.O("initialTurn")), "concise") { + t.Fatal("personal settings entered the prompt") + } +} + +// A reachable legacy Node has no v1 adapter proof; the run settles before any first prompt. +func TestDeclaredAgentCapabilityRefusalSettlesBeforePrompt(t *testing.T) { + scene, workspace := declaredStartupScene(t) + bindConnectedNode(t, scene.store, workspace) + if _, err := scene.store.Pool.Exec("UPDATE node_instances SET model_proxy=true WHERE workspace_id=$1", workspace); err != nil { + t.Fatal(err) + } + startupPass(t, scene.store) + phase, status, reason, _, _, _ := runFields(t, scene.store, scene.seed.run) + if phase.String != "releasing" || status != "failed" || reason != "agent_adapter_unavailable" { + t.Fatalf("missing adapter did not settle: %s/%s/%s", phase.String, status, reason) + } + if _, _, _, _, present := firstTurn(t, scene.store, scene.seed.run); present { + t.Fatal("incompatible Node received a prompt") + } +} diff --git a/internal/core/execution_startup.go b/internal/core/execution_startup.go index 093b743e5..1b28782b6 100644 --- a/internal/core/execution_startup.go +++ b/internal/core/execution_startup.go @@ -3,10 +3,11 @@ package core // agentStartupEvidence is control-plane evidence read under the dispatch transaction lock. // Registration is a conservative boundary: even a missing acknowledgement can have reached Node. type agentStartupEvidence struct { - registered bool - modelUnavailable bool - expired bool - endReason string + registered bool + modelUnavailable bool + adapterUnavailable bool + expired bool + endReason string } // inspectAgentStartup keeps execution, command and Node table reads with their control-plane owner. @@ -26,13 +27,15 @@ func inspectAgentStartup(t *transaction, run Object) agentStartupEvidence { if input.S("modelBindingId") != "" { // Only an initialized, reachable current incarnation supplies definitive capability evidence. // The connected flag can outlive its heartbeat, so use dispatch's database freshness bound. - node := t.one(`SELECT n.model_proxy FROM node_instances n + node := t.one(`SELECT n.model_proxy,n.model_adapter FROM node_instances n JOIN sandbox_instances si ON si.id=n.sandbox_instance_id JOIN workspaces w ON w.id=n.workspace_id AND w.runtime_generation=si.generation WHERE n.workspace_id=$1 AND n.connection_state='connected' AND n.initialized AND n.ended_at IS NULL AND si.terminated_at IS NULL AND n.last_seen_at>clock_timestamp()-interval '30 seconds'`, wid) evidence.modelUnavailable = node != nil && !node.B("modelProxy") + configuration := t.one(`SELECT c.target,c.adapter_contract_version AS adapter_version,b.protocol FROM run_agent_configurations c JOIN run_model_bindings b ON b.run_id=c.run_id WHERE c.run_id=$1`, rid) + evidence.adapterUnavailable = node != nil && configuration != nil && !declaredAgentCapable(node, configuration) } evidence.expired = t.one("SELECT id FROM issue_runs WHERE id=$1 AND startup_deadline_at<=clock_timestamp()", rid) != nil diff --git a/internal/core/migrations/0060_node_model_adapter.sql b/internal/core/migrations/0060_node_model_adapter.sql new file mode 100644 index 000000000..98b2d08fa --- /dev/null +++ b/internal/core/migrations/0060_node_model_adapter.sql @@ -0,0 +1,3 @@ +-- Older incarnations have no proof of the declared-adapter startup contract. +ALTER TABLE node_instances ADD COLUMN model_adapter jsonb NOT NULL DEFAULT '{}'::jsonb + CHECK(jsonb_typeof(model_adapter)='object'); diff --git a/internal/core/migrations/README.en.md b/internal/core/migrations/README.en.md index dc00b12fc..b1ae139c6 100644 --- a/internal/core/migrations/README.en.md +++ b/internal/core/migrations/README.en.md @@ -6,7 +6,7 @@ SQL is embedded in the binary through `embed.FS` in `core/store.go`. `Store.Migr ## File index -There are currently 61 SQL files. +There are currently 62 SQL files. | SQL file | Schema change | |---|---| @@ -71,6 +71,7 @@ There are currently 61 SQL files. | [0057_agent_run_residual_reclamation.sql](0057_agent_run_residual_reclamation.sql) | Residual reclamation responsibility, appended evidence and disposition | | [0058_agent_run_controlled_reclamation.sql](0058_agent_run_controlled_reclamation.sql) | Deployment-controlled reclamation intents, dependent data deletion and per-target termination | | [0059_agent_restore_stop_evidence.sql](0059_agent_restore_stop_evidence.sql) | Named unconfirmed-stop evidence without changing residual responsibility or reclaim authority | +| [0060_node_model_adapter.sql](0060_node_model_adapter.sql) | Plugin model-adapter capability declared by a Node instance at registration | 0050 copies no personal connection or key and backfills no historical run. Clearing configuration retains its revision; run options are stored separately from shared run input. @@ -93,6 +94,7 @@ There are currently 61 SQL files. - 0057 only adds residual-ledger columns: defaults initialize existing D4/D5 rows as `reconciling`, without backfilling targets or evidence, rewriting old `reason`, `responsibility` or business settlement, or backfilling D8. Legacy rows with non-null `cleared_at` cause an explicit migration error and rollback pending compatibility handling (A2). Reentrant DDL preserves progressed rows. The schema accepts `released`, `terminated_data_retained` and `externally_confirmed`; this step's automatic path implements only `released`, leaving the other dispositions to later controlled entry points. The authorization transaction checks the operation's tenant and Workspace relationships; a UUID foreign key alone does not establish resource scope. See [agent_run_residual_reclaim_migration_test.go](../../../integration/agent_run_residual_reclaim_migration_test.go) for fresh, upgrade, DDL replay and constraint coverage. - 0058 preserves 0057 history, responsibility, evidence, disposition and first `cleared_at`, and never backfills D8. Only an explicit root intent owns protection and evidence for ledgerless D8. Root termination and dependent data deletion each have three states; each resource has one root, at most one deletion intent and one fixed deletion operation. Command keys are unique throughout a tenant. Successful targets require both boolean confirmations, so a missing JSON field cannot pass through SQL NULL. Fresh, 0057-upgrade and constraint coverage is in [agent_run_controlled_reclaim_migration_test.go](../../../integration/agent_run_controlled_reclaim_migration_test.go). - 0059 only adds `stop_evidence`, initially empty for existing rows; no historical stop observation is invented. Covered by `TestMigration0059RestoreStopEvidenceFreshUpgradeAndRepeat`. +- 0060 adds `model_adapter` to `node_instances` (a jsonb object, default `{}`) recording the plugin model-adapter capability a Node instance declares at registration. Nothing is backfilled: existing instances count as having no capability and receive no session that needs an adapter until they register again with a newer version. Fresh and upgrade coverage is in `TestMigration0060ModelAdapterFreshAndUpgrade`. - New migrations must verify fresh databases, upgrades from the preceding schema, repeated application and key constraints. See [migration_upgrade_path_test.go](../../../integration/migration_upgrade_path_test.go) and domain upgrade tests in integration. See [Persistence](../../../docs/core/persistence.en.md) for ownership/transactions, and [Workflows](../../../docs/workflows/workflows.en.md) / [External Git line](../../../docs/repositories/external-git.en.md) for runtime semantics. diff --git a/internal/core/migrations/README.md b/internal/core/migrations/README.md index 41852d4ca..2b6a55a31 100644 --- a/internal/core/migrations/README.md +++ b/internal/core/migrations/README.md @@ -6,7 +6,7 @@ SQL 通过 `core/store.go` 的 `embed.FS` 编入二进制。`Store.Migrate` 在 ## 文件索引 -当前共 61 个 SQL 文件。 +当前共 62 个 SQL 文件。 | SQL 文件 | schema 变化 | |---|---| @@ -71,6 +71,7 @@ SQL 通过 `core/store.go` 的 `embed.FS` 编入二进制。`Store.Migrate` 在 | [0057_agent_run_residual_reclamation.sql](0057_agent_run_residual_reclamation.sql) | 残留回收责任、追加证据和处置状态 | | [0058_agent_run_controlled_reclamation.sql](0058_agent_run_controlled_reclamation.sql) | 部署受控回收意图、从属数据删除与逐目标终止 | | [0059_agent_restore_stop_evidence.sql](0059_agent_restore_stop_evidence.sql) | 停止未确认的命名证据,不改变残留责任和回收授权 | +| [0060_node_model_adapter.sql](0060_node_model_adapter.sql) | Node 实例登记的插件模型 adapter 能力 | 0050 不复制个人连接或 Key,不回填历史运行;清除配置保留 revision,运行选项独立于共享运行输入保存。 @@ -93,6 +94,7 @@ SQL 通过 `core/store.go` 的 `embed.FS` 编入二进制。`Store.Migrate` 在 - 0057 只给残留账本增列:存量 D4/D5 行通过默认值初始化为 `reconciling`,不回填目标或证据,不改旧 `reason`、`responsibility` 或业务结算,也不回填 D8。历史行若已有非空 `cleared_at`,迁移明确报错并回滚,等待显式兼容处理(A2)。DDL 可重入,不会重置已推进条目。数据库接受 `released`、`terminated_data_retained`、`externally_confirmed` 三种处置;本步自动路径只实现 `released`,其余处置留给后续受控入口。operation 的租户和 Workspace 关联由授权事务比对,UUID 外键本身不证明资源范围。空库、升级、DDL 重放和约束覆盖见 [agent_run_residual_reclaim_migration_test.go](../../../integration/agent_run_residual_reclaim_migration_test.go)。 - 0058 不改 0057 的历史列、责任、证据、处置或首次 `cleared_at`,也不回填 D8;无账本的 D8 只由显式根意图承载保护与证据。根终止与从属删卷各自三态;每资源一个根、最多一个从属删除意图及固定删除 operation,命令键在 tenant 范围统一唯一。目标的成功约束要求两个布尔确认均为 true,缺字段不能以 SQL NULL 通过。新库、0057 升级与约束覆盖见 [agent_run_controlled_reclaim_migration_test.go](../../../integration/agent_run_controlled_reclaim_migration_test.go)。 - 0059 只增 `stop_evidence`,存量为空对象,不追认历史未知停止;覆盖见 `TestMigration0059RestoreStopEvidenceFreshUpgradeAndRepeat`。 +- 0060 给 `node_instances` 增加 `model_adapter`(jsonb 对象,默认 `{}`),记录 Node 实例登记时声明的插件模型 adapter 能力;不回填,既有实例按空能力处理,不会被派发需要 adapter 的会话,直到以新版本重新登记。空库与升级覆盖见 `TestMigration0060ModelAdapterFreshAndUpgrade`。 - 新增迁移须验证空库、前一 schema 升级、重复应用与关键约束。覆盖见 [migration_upgrade_path_test.go](../../../integration/migration_upgrade_path_test.go) 和 integration 的领域升级测试。 数据归属与事务规则见 [持久化](../../../docs/core/persistence.md),运行语义见 [工作流](../../../docs/workflows/workflows.md) 和 [外部 Git 线](../../../docs/repositories/external-git.md)。 diff --git a/internal/core/node_execution.go b/internal/core/node_execution.go index b8c664e4b..4af7e0039 100644 --- a/internal/core/node_execution.go +++ b/internal/core/node_execution.go @@ -83,6 +83,7 @@ func dispatchRunExecution(t *transaction, r *ControlRequest, runID, execution, n if input.S("modelBindingId") != "" { require(current.B("modelProxy"), 409, "model_proxy_required") } + require(declaredAgentCapable(current, input.O("agentConfiguration")), 409, "agent_adapter_required") // An IssueRun runs exactly one agent session in its life. A WorkflowRun runs one per agent // node (D4 executes them serially through one Workspace), so its sessions are bounded by the // work item this dispatch consumes — the unregistered item is gone once dispatched (below), diff --git a/internal/core/node_report.go b/internal/core/node_report.go index 73937244e..40847c2ca 100644 --- a/internal/core/node_report.go +++ b/internal/core/node_report.go @@ -48,12 +48,14 @@ func registerReportedNode(t *transaction, r *ControlRequest) Object { nodeID, incarnation := r.Body.S("nodeId"), r.Body.S("nodeIncarnationId") require(validID(sid) && generation > 0 && nodeID != "" && len(nodeID) <= 200 && incarnation != "" && len(incarnation) <= 200, 400, "invalid_node_report") require(r.Body.N("protocolVersion") == 1, 400, "unsupported_protocol") + require(validAgentAdapterCapability(r.Body.O("modelAdapter"), r.Body.B("modelProxy")), 400, "invalid_node_report") sandbox := t.one("SELECT * FROM sandbox_instances WHERE id=$1", sid) require(sandbox != nil, 404, "not_found") w := nodeScope(t, sandbox.S("workspaceId"), sid, generation) if existing := t.one("SELECT * FROM node_instances WHERE sandbox_instance_id=$1 AND node_incarnation_id=$2", sid, incarnation); existing != nil { require(existing.S("nodeId") == nodeID && existing["endedAt"] == nil, 409, "stale_node") require(existing.B("modelProxy") == r.Body.B("modelProxy"), 409, "node_capability_conflict") + require(jsonText(existing.O("modelAdapter")) == jsonText(r.Body.O("modelAdapter")), 409, "node_capability_conflict") return existing } require(w.S("desiredState") == "running", 409, "execution_closed") @@ -65,6 +67,6 @@ func registerReportedNode(t *transaction, r *ControlRequest) Object { // The handshake is complete when the Controller reports: a desktop Node finishes its own // recovery before it accepts a session, so the incarnation is initialized from the start. id := newID() - t.exec("INSERT INTO node_instances(id,sandbox_instance_id,workspace_id,service_subject,connection_state,protocol_version,initialized,node_id,node_incarnation_id,model_proxy) VALUES($1,$2,$3,$4,'connected',1,true,$5,$6,$7)", id, sid, w.S("id"), r.Service.Subject, nodeID, incarnation, r.Body.B("modelProxy")) + t.exec("INSERT INTO node_instances(id,sandbox_instance_id,workspace_id,service_subject,connection_state,protocol_version,initialized,node_id,node_incarnation_id,model_proxy,model_adapter) VALUES($1,$2,$3,$4,'connected',1,true,$5,$6,$7,$8)", id, sid, w.S("id"), r.Service.Subject, nodeID, incarnation, r.Body.B("modelProxy"), jsonText(r.Body.O("modelAdapter"))) return t.one("SELECT * FROM node_instances WHERE id=$1", id) } diff --git a/proto/ora/cloud/internal/v1/agent_executions.proto b/proto/ora/cloud/internal/v1/agent_executions.proto index 5871ccf74..5243d2402 100644 --- a/proto/ora/cloud/internal/v1/agent_executions.proto +++ b/proto/ora/cloud/internal/v1/agent_executions.proto @@ -82,6 +82,32 @@ message AgentSessionSpec { string model_binding_id = 7; // Workspaces of this Project, newest first, at most 100, fixed when the work item was released. repeated WorkspaceListing workspace_directory = 8; + // Exact non-secret configuration frozen at run admission; authority is acquired over mTLS. + AgentModelConfiguration agent_configuration = 9; +} + +// Versioned adapter identity, never a runtime token or upstream credential. +message AgentModelConfiguration { + string plugin_id = 1; + string plugin_version = 2; + string target = 3; + string package_sha256 = 4; + uint32 schema_version = 5; + string declaration_fingerprint = 6; + uint32 adapter_version = 7; + uint64 configuration_revision = 8; + // Canonical scalar JSON preserves number lexemes that protobuf Struct would round. + string settings_json = 9; + string protocol = 10; + AgentBoundModel model = 11; +} + +// Model catalog metadata frozen in the binding, independent of live personal configuration. +message AgentBoundModel { + string id = 1; + string name = 2; + uint64 context_window = 3; + uint64 max_tokens = 4; } enum AgentSessionEndReason { diff --git a/proto/ora/cloud/internal/v1/nodes.proto b/proto/ora/cloud/internal/v1/nodes.proto index 14caae6fb..7d62c4cd0 100644 --- a/proto/ora/cloud/internal/v1/nodes.proto +++ b/proto/ora/cloud/internal/v1/nodes.proto @@ -52,6 +52,7 @@ message NodeRecord { optional int64 idle_admission_epoch = 8; // Dedicated personal-model proxy access was advertised by this Node's handshake. bool model_proxy = 9; + ModelAdapterCapabilities model_adapter = 10; } message RegisterNodeRequest { @@ -64,6 +65,14 @@ message RegisterNodeRequest { uint32 protocol_version = 6; // False for older Nodes or Nodes without configured model-access mTLS credentials. bool model_proxy = 7; + ModelAdapterCapabilities model_adapter = 8; +} + +// Exact host capability advertised after startup validation, independent of plugin metadata. +message ModelAdapterCapabilities { + uint32 contract_version = 1; + string target = 2; + repeated string protocols = 3; } message RegisterNodeResponse {