Compare commits

...
Author SHA1 Message Date
weichenandCursor dc47c10d68 Add agent_system_identity marker with ensure/verify CLI
Why: U1 I3 needs a DB-side instance identity anchor matching
manifest.instance_id so wrong/missing DB blocks startup (I4).
Evidence: go test ./internal/store/ ./internal/app/; CLI smoke
ensure/verify on SQLite temp dir.
Impact: migration v26; store Ensure/Verify; identity subcommand;
serve checks AGENT_SYSTEM_INSTANCE_ID when set.
Refs: docs/recovery/u1-identity-manifest.md §5.2; Dev feature/recovery-u1-db-identity

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 04:04:26 +00:00
weichen cb386023d0 Merge pull request 'Keep auto-present reminders synchronous to avoid run races' (#172) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Failing after 58s
2026-10-12 03:57:11 +00:00
weichenandCursor 9bd0aae9d8 Keep auto-present reminders synchronous to avoid run races
ci / vet-and-test (pull_request) Successful in 56s
Why: Async notice-run delivery raced with test teardown and in-flight
conversation runs (database closed / material lifecycle flakes).
Evidence: go test ./internal/runmgr/ -run Material
Impact: Reminders append as system messages only; material path still
inlines into startNoticeRun.
Refs: Dev PR #425 CI test-pg failure

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:56:59 +00:00
weichen b6ea1a5910 Merge pull request 'Drop runmgr import of genui to satisfy package boundaries' (#169) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Successful in 1m1s
2026-10-12 03:48:28 +00:00
weichenandCursor 41df7ae56f Drop runmgr import of genui to satisfy package boundaries
ci / vet-and-test (pull_request) Successful in 1m10s
Why: lint-boundaries forbids backend/runmgr -> backend/genui; keep the
auto trigger as an injected interface with local reminder text.
Evidence: bash scripts/lint-boundaries.sh
Impact: No behavior change; wiring still via app.SetGenUIAuto(genui.NewAuto).
Refs: architecture boundaries

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:48:10 +00:00
weichen 9b69117bea Merge pull request 'Add Generative UI auto-present opt-in with budget and cooldown' (#168) from feature/genui-auto-trigger-optin into main
ci / vet-and-test (push) Failing after 1m4s
2026-10-12 03:45:03 +00:00
weichenandCursor 6ff7fa9bd7 Add Generative UI auto-present opt-in with budget and cooldown
ci / vet-and-test (pull_request) Successful in 1m4s
Why: Known-boundary follow-up: owner opt-in auto page suggestions on
semantic task events without self-trigger from surface revisions.
Evidence: go test ./internal/genui/ ./internal/runmgr/ ./internal/settings/ ./internal/app/
Impact: Default-off settings key; material/plan/todo/document hooks nudge present.
Refs: docs/proposals/2026-10-12-generative-ui-auto-trigger.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:44:47 +00:00
weichen d1b5a94d15 Merge pull request 'Whitelist B-class surface actions for help and intent' (#167) from feature/genui-bclass-help-intent into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-12 03:23:57 +00:00
weichen ba619747c5 Merge authoritative main into the B-class branch
ci / vet-and-test (pull_request) Successful in 1m12s
2026-10-12 03:23:43 +00:00
weichenandCursor 5e0077dc11 Whitelist B-class surface actions for help and intent
ci / vet-and-test (pull_request) Successful in 48s
Why: Generative UI U5 left the B-class action allowlist empty; owner
authorized first entries builtin.help and system.intent.
Evidence: go test ./internal/genui/ ./internal/surfacesvc/ ./internal/server/
Impact: Surface buttons may bind help/intent; button-entrance intent
matches only and does not nest-dispatch write commands.
Refs: docs/proposals/2026-10-12-generative-ui-bclass-help-intent.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:18:57 +00:00
weichen b160515905 Merge pull request 'Unwrap item-style spec wrappers, pair bare table cells, let subagents present pages' (#166) from feature/genui-spec-repair into main
ci / vet-and-test (push) Successful in 55s
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/166
2026-10-12 03:09:35 +00:00
weichen 9ec9f9a010 Merge pull request 'Harden cross-instance HITL/cancel command paths and add disk-full rejection tests (U7)' (#164) from feature/u7-load-fault-release into main
ci / vet-and-test (push) Successful in 59s
2026-10-12 03:09:03 +00:00
weichen 58598df095 Merge remote-tracking branch 'origin/main' into feature/u7-load-fault-release
ci / vet-and-test (pull_request) Successful in 51s
2026-10-12 03:07:33 +00:00
weichen 3d850a282f Unwrap item-style spec wrappers, pair bare table cells, let subagents present pages
ci / vet-and-test (pull_request) Successful in 1m4s
Why: MiniMax-M3 emits XML-RPC-style {"item": ...} single-key wrappers on
array fields (kv/cells/items), bare scalar table cells, and page
submission delegated to the general sub-agent had no generative-ui
tool - observed live on dev (events seq 1395/1396/2171) where every
present with kv/table/list nodes failed invalid-spec and the delegated
retry reported the tool missing.

Evidence: red test reproducing the exact production error
(.nodes.1.kv of type [][2]string) goes green after the repair; full
genui/prompts/runtime/runmgr suites pass (SELF); mismatched-length
cells stay untouched by a dedicated guard test.

Impact: generative-ui pages with kv/table/list nodes save successfully
again; the general sub-agent can submit pages (tool resolves only when
the plugin exposes it, so absent plugins are unaffected).

Refs: dev task card feature/genui-spec-repair; dev conversation
激活验证-1012.
2026-10-12 03:03:26 +00:00
weichen a80d3c3359 Merge pull request 'Declare admin-zone sections from compiled plugins (surface 2.9.0, WP3-I3)' (#165) from feature/adminzone-plugin-sections into main
ci / vet-and-test (push) Successful in 46s
2026-10-12 02:39:56 +00:00
weichen 1719b5f668 Merge authoritative main into the admin-zone branch
ci / vet-and-test (pull_request) Successful in 49s
2026-10-12 02:37:57 +00:00
weichen 62bfc6068a Declare admin-zone sections from compiled plugins (surface 2.9.0)
ci / vet-and-test (pull_request) Successful in 46s
Why: the admin zone could not host plugin-declared partitions - KTD4
requires the RDM audit partition to be reachable by RDM domain admins and
base superadmins without hardcoding RDM into the base AdminZone.
Evidence: go test ./internal/app ./internal/server ./pkg/extension green
incl. new admin-zone recording/endpoint tests; surface snapshot test
green after surface.txt update; CI (Dev composite) is authoritative.
Impact: extension surface 2.8.0 -> 2.9.0 (Registry.AddAdminZone +
AdminZoneSpec, fail-closed on invalid specs); applyCompiled records
declarations into the plugin summary; new authenticated GET
/api/admin-zone/sections returns the flattened, ordered declaration list.
Entry visibility stays metadata-only - data capability remains enforced
by each section's own command surface (fail-closed).
Refs: WP3-I3/KTD4; task card feature-adminzone-plugin-sections.
2026-10-12 02:28:51 +00:00
weichen 0a7413903d Harden cross-instance HITL/cancel command paths and add disk-full rejection tests (U7)
ci / vet-and-test (pull_request) Successful in 48s
Why: two-process HTTP acceptance (Dev tests/integration/multi-instance, added in
the matching Dev-side change) exposed three real cross-instance defects that the
U5 library-level tests bypassed via direct Manager calls, plus the U7 fault
matrix needs explicit disk-full rejection evidence.
Evidence: go test ./... green on SQLite and real PG (AGENT_PG_TEST_DSN);
new regressions TestCrossInstanceResolveDoesNotSettleCard (runmgr, PG-gated),
TestUploadRejectsCleanlyWhenObjectDirUnwritable and
TestArtifactOverwriteRejectsCleanlyWhenObjectDirUnwritable (server).
Impact: ResolveHITL no longer settles the card when the run executes on another
instance (command-bus fallback stays available); cancelRun enqueues the command
with the real conversation id and no longer reports a false local success;
object-store failures now provably leave zero DB/object residue.
Refs: collaboration storage proposal U7 (sections 3/5/7); task card
feature/u7-load-fault-release.
2026-10-12 02:28:48 +00:00
weichen 696ef15168 Merge pull request 'Add project-level surface aggregation and task-phase guidance (U6)' (#163) from feature/generative-ui-u6 into main
ci / vet-and-test (push) Successful in 53s
2026-10-12 02:12:59 +00:00
weichen add1fbb045 Add project-level surface aggregation and task-phase guidance (U6)
ci / vet-and-test (pull_request) Successful in 54s
Why: U6 extends pages from conversations into the task/project
workspace - surfaces aggregate across conversations under the owner's
project scope (project resolved authoritatively from the conversation
row, never self-reported by the model), and the catalog guidance now
organizes pages by task phase (requirements/execution/acceptance)
while stating that task facts come only from real task records.

Evidence: build/vet clean; surfacesvc suite green incl. the new
project-aggregation test (owner isolation, cross-owner zero, history
retained post-disable); genui/store suites green.

Impact: New endpoint GET /api/projects/{id}/surfaces; Submit resolves
project from conversation; catalog text guidance. Existing endpoints
unchanged.

Refs: proposal r2 U6; Dev task feature/generative-ui-u6
2026-10-12 02:12:42 +00:00
weichen d835ad7466 Merge pull request 'List compiled plugins in /api/plugins (WP3-I4)' (#162) from feature/plugins-compiled-listing into main
ci / vet-and-test (push) Successful in 45s
2026-10-12 01:59:21 +00:00
weichen 9aa51e5250 List compiled plugins in /api/plugins
ci / vet-and-test (pull_request) Successful in 47s
Why: compiled-in plugins (generative-ui, rdm) never appeared in the admin
plugins list: GET /api/plugins only scanned the directory host, so binary-
level capabilities were invisible to operators (user-reported gap).
Evidence: go test ./internal/app/ ./internal/server/ green locally incl.
new TestApplyCompiledRecordsSummary and TestListPluginsMergesCompiled;
CI (make test) is authoritative.
Impact: applyCompiled records each plugin's registered commands/intents/
skills into a summary handed to the server; /api/plugins merges synthesized
inproc entries (backend.mode=inproc, apiVersion=extension surface version,
provides from the registration record). Directory entries win on id
collision. No public extension-surface change.
Refs: WP3-I4; task card feature-plugins-compiled-listing.
2026-10-12 01:44:56 +00:00
weichen e49f165d0b Merge pull request 'Material-intake guaranteed loop: deterministic spawn, choice chain, queue, cardflow on (Dev task feature/material-intake-flow)' (#161) from feature/material-intake-flow into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 17:47:56 +00:00
weichen 409390fff1 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Successful in 42s
2026-10-11 17:37:51 +00:00
weichen 4c637d4071 Merge pull request 'Repair object-shaped children maps into arrays in page specs' (#160) from fix/genui-children-repair into main
ci / vet-and-test (push) Successful in 49s
2026-10-11 16:57:51 +00:00
weichen 432b916cba Repair object-shaped children maps into arrays in page specs
ci / vet-and-test (pull_request) Successful in 52s
Why: Live E2E on the dev instance - the model keyed section children
as an object map (children.item.*), the strict decode rejected it, and
the page fell back to a file artifact; the guidance fix made the model
call present, this closes the remaining shape gap.

Evidence: genui suite green incl. the reproduction case; post-deploy
E2E rerun recorded in the Dev task.

Impact: Repair strategy only; validation unchanged.

Refs: Dev task fix/genui-page-visibility (E2E follow-up)
2026-10-11 16:57:48 +00:00
weichen 16ed7b281b Add background queue for system tasks and flip ui.cardflow default on
Why: at the 3-per-conversation cap the guaranteed material task was dropped silently, and long deliverables stayed in chat because cardflow defaulted off (user ruling 2026-10-11).
Evidence: new queue lifecycle test (queued card, hard-reject for model path, FIFO start on release, card promote) passes; full go test ./... exit 0 (local, SELF).
Impact: queueable system spawns create a queued progress card at capacity and start FIFO when a slot frees (memory-only queue; restart limitation noted); model delegate_task still hard-rejects; ui.cardflow default true (EffectNewRun).
Refs: Dev task .task/feature-material-intake-flow.md I3.
2026-10-11 16:56:59 +00:00
weichen 42867aabf4 Add deliverable-choice chain: confirm_choice tool, notice contract, backstops
Why: after material organization completes, weak models report and end the turn without offering the user a choice of formal deliverables, and even after a user picks one the model may close the turn without starting the background task.
Evidence: new tests (notice-choice chain, deliverable backstop, free-input path, invalid args, notice contract) pass; full go test ./... exit 0 (local, SELF).
Impact: new confirm_choice tool issues a HITL choice card and routes the answer into a hard delegate instruction; material notices instruct the model to report then offer choices; stop hook adds two one-shot backstops (choice not offered in material notice runs; choice made but no background delegation).
Refs: Dev task .task/feature-material-intake-flow.md I2.
2026-10-11 16:53:53 +00:00
weichen 02858f486b Add deterministic material-intake background path for attachment sends
Why: the dual-speed entry hint is advisory only; real runs (MiniMax, 3 attachments, no text) end with a clarifying question and zero delegation, so background cards and result backfill never start.
Evidence: new unit tests in runmgr/material_test.go and runtime/material_flow_test.go all pass; full go test ./... exit 0 (local, SELF).
Impact: attachment-carrying runs now guarantee a material-organization background task (pre-spawn on attachment-only/heavy sends, stop-hook backstop otherwise when the model did not delegate); subagent receives inline parsed text with budget plus knowledge_search guidance; content-key dedup prevents duplicate spawns (in-flight, completed, resend); model-authored delegate_task gains a materials appendix; settings runtime.materialIntake (default on) and runtime.materialIntakeChars added.
Refs: Dev task .task/feature-material-intake-flow.md I1; user ruling 2026-10-11.
2026-10-11 16:45:49 +00:00
weichen faae3d3901 Merge pull request 'Add model identity and in-database vector top-k via pgvector (U6 slice 3)' (#159) from feature/collab-storage-u6-vector into main
ci / vet-and-test (push) Successful in 10m52s
2026-10-11 16:31:25 +00:00
weichen 9b33df6625 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 10m28s
2026-10-11 16:31:15 +00:00
weichen 5d5cef9fcd Add model identity and in-database vector top-k via pgvector (U6 slice 3)
ci / vet-and-test (pull_request) Successful in 56s
Why: plan U6 vector slice (KTD11 correctness step) - same-dimension vectors from different models must never mix, and PG tiers should run filtered exact top-k in the database instead of scanning every row in-process.

Evidence: full suite green on SQLite and real PostgreSQL 18.6 with pgvector (35 packages each tier) - pgvector_test covers model-identity and dimension isolation, update/delete atomicity (BLOB and vec_pg swap together), shared top-5 with score monotonicity, Recall@10 = exact-scan parity, deny-all predicate leakage; storage-migrate tests green after excluding the derived vec_pg column.

Impact: v25 migration adds model/content_hash columns and (PG, best-effort DO block) the pgvector extension plus vec_pg column - absence degrades to in-process scan; UpsertKnowledgeVectorExt dual-writes BLOB (cross-tier authority) and vec_pg; SearchKnowledgeVectorsModel/SharedModel filter by model identity and use SQL exact top-k (ORDER BY vec_pg <=> q) with automatic degradation; embedders expose Model() and the rag pipeline threads it through indexing and both retrieve paths; storage-migrate excludes vec_pg as a derived column.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U6 slice 3, KTD11; R9.
2026-10-11 16:31:12 +00:00
weichen 1fa03a3cbb Merge pull request 'Add AdminProvider capability and HostProjectID context (EXT-18)' (#158) from feature/base-ext-admin-project into main
ci / vet-and-test (push) Successful in 54s
2026-10-11 16:23:31 +00:00
weichen 4ae125a3ef Add AdminProvider capability and HostProjectID context (EXT-18)
ci / vet-and-test (pull_request) Successful in 57s
Why: RDM KTD4 dual-layer admin requires base isAdmin visibility (superuser slot); KTD5 host-project binding needs trusted current-project context.

Evidence: SELF 4 ext18 tests pass + surface snapshot regenerated + full pkg/extension + internal/app suites green.

Impact: extension.Request gains HostProjectID; optional AdminProvider interface (EXT-17 pattern); inprocHost implements IsAdmin via GetUserAuthState; three dispatch sites set HostProjectID from conversation.

Refs: RDM docs/proposals U1 base extension requests
2026-10-11 16:23:15 +00:00
weichen 25fbea8522 Merge pull request 'Add same-transaction outbox, S3-compatible blobstore, and real-process acceptance (U5 remainder)' (#157) from feature/collab-storage-u5-outbox into main
ci / vet-and-test (push) Successful in 1m4s
2026-10-11 15:58:18 +00:00
weichen e1dc93d147 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Successful in 1m10s
2026-10-11 15:58:03 +00:00
weichen eb4796cf61 Add same-transaction outbox, S3-compatible blobstore, and real-process acceptance (U5 remainder)
ci / vet-and-test (pull_request) Successful in 46s
Why: plan U5 remainder (KTD5/KTD9 + exit criteria) - commands must commit atomically with business writes, artifacts need a shared object store for multi-instance, and the two-real-process acceptance matrix must run in CI.

Evidence: full suite green on SQLite and real PostgreSQL 18.6 (35 packages each tier) - outbox tx test (rollback removes the command, commit makes both visible, idempotent re-enqueue), S3 store tests (SigV4-signed round trip, 404 sentinel, idempotent delete, presigned GET, env fallback), real-process acceptance (testbinary self-spawn of two serve processes with per-process identity and command consumers against one schema: same-conversation contention, cross-instance cancel command claimed exactly once, stale-owner settle fenced with run unchanged).

Impact: EnqueueCommandInTx for in-transaction command outbox and AppendEvents now notifies in the same transaction (KTD6 front half); blobstore.S3 (SigV4, presign, prefix isolation) injected from BLOBSTORE_S3_* env when configured, local fallback otherwise; realproc_test provides the process-level multi-instance acceptance gate (PG-env gated, runs in CI test-pg job).

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U5 KTD5/KTD9, section 5 exit criteria; R4/R7/R10.
2026-10-11 15:57:12 +00:00
weichen 1df183b104 Merge pull request 'Guide the model to present pages for page-like deliverables' (#156) from fix/genui-page-visibility into main
ci / vet-and-test (push) Successful in 47s
2026-10-11 15:56:08 +00:00
weichen 820aa4432e Guide the model to present pages for page-like deliverables
ci / vet-and-test (pull_request) Successful in 47s
Why: Field evidence from the dev instance - the present tool was
projected and enabled but never called, because every resident prompt
module steers 'make me a report' toward file artifacts; the tool
description carried only the component catalog, not when to use it.

Evidence: genui suite green (catalog assertion extended implicitly by
existing shape test); end-to-end model behavior verified post-deploy
in the Dev task.

Impact: Tool description text only (self-gating: description appears
only when the activation gate projects the tool).

Refs: Dev task fix/genui-page-visibility
EOF
)
2026-10-11 15:44:33 +00:00
weichen eb12f102f2 Merge pull request 'Add the surface action pipeline: ledger, guards, dispatch, submit (U5)' (#155) from feature/generative-ui-u5 into main
ci / vet-and-test (push) Successful in 1m6s
2026-10-11 14:24:34 +00:00
weichen f5eb27df86 Add the surface action pipeline: ledger, guards, dispatch, submit (U5)
ci / vet-and-test (pull_request) Successful in 1m10s
Why: The approved U5 scope needs the action half of the loop: an
idempotent ledger (migration v24), a single-choke SubmitAction (plugin
activation, current-revision, action whitelist, params-hash
idempotency), dispatch through the command bus so DangerHigh lands on
the existing inline confirm card with no Confirmed passthrough (the
endpoint does not even bind the field), the button-only
generative-ui.submit storing form data in the plugin KV ledger, and
catalog discipline rejecting unknown action ids at present time.

Evidence: go build/vet clean; full go test ./... green; surfacesvc/
genui/store suites also green under AGENT_PG_TEST_DSN; red matrix
covers disabled/stale-revision/cross-owner/unknown-action/idempotent
same-and-different-params/danger-high-await-confirm and the endpoint
forged-confirmed case.

Impact: Additive migration v24 + new endpoint/command; existing
endpoints and command semantics untouched (B-class whitelist starts
empty per the approved scope).

Refs: U5 action-scope r2 (approved, Documents #71); UI #46/#47;
Frontend #56; Dev task feature/generative-ui-u5
2026-10-11 14:24:31 +00:00
weichen 44d45e1092 Merge pull request 'Re-pin agent-system-core to 89f3906 to match Dev core' (#154) from feature/core-repin-89f3906 into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-11 13:54:32 +00:00
weichen ec9d5f78fe Re-pin agent-system-core to 89f3906 to match Dev core
ci / vet-and-test (pull_request) Successful in 1m7s
Why: PR #152 pinned the bundled core back to 7a98a7e citing "Dev core is
at 7a98a7e", but Dev origin/main pins modules/core at 89f3906; the
misalignment breaks Dev check-pins for any tree that materializes
recorded pointers (exposed by a fresh task worktree running make test).

Evidence: go build ./... and internal/server + internal/store tests
green on the repinned tree.

Impact: restores modules/backend/deps/agent-system-core == Dev
modules/core (89f3906), unblocking Dev gitlink bumps.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:52:34 +00:00
weichen 51ad3ce115 Merge pull request 'Fall back to session cookie on stale Bearer key and report authVia' (#153) from feature/admin-auth-gate-fix into main
ci / vet-and-test (push) Successful in 47s
2026-10-11 13:48:25 +00:00
weichen 6681c3b47c Fall back to session cookie on stale Bearer key and report authVia
ci / vet-and-test (pull_request) Successful in 44s
Why: a browser holding a revoked API key in localStorage sent the Bearer
header on every request; key precedence 401'd all APIs despite a valid
login session, locking admins out of user management. /api/me also
reported mustChangePassword for key principals, who cannot change
passwords (session-owned), dead-ending the forced-change gate.

Evidence: new TestStaleKeySessionFallback covers fallback and authVia;
TestAuthMiddleware/TestAdminAccountFlow unchanged and green; full
go test ./... green.

Impact: invalid key falls back to a valid session cookie before 401
(cookie-less programmatic clients unaffected); /api/me gains
authVia ("key"|"session"|""), letting the frontend keep key holders
out of the change-password gate.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:52 +00:00
weichen 9a1c911e53 Merge pull request 'Pin agent-system-core to 7a98a7e (align with Dev core)' (#152) from feature/u5-core-pin into main
ci / vet-and-test (push) Failing after 0s
2026-10-11 12:30:58 +00:00
weichen bfeeefab94 Pin agent-system-core to 7a98a7e (align with Dev core)
ci / vet-and-test (pull_request) Failing after 1s
Why: Dev modules/core is at 7a98a7e7 (core PR #26); backend's internal pin lagged at 89f3906 after a parallel merge, failing Dev check-pins.

Evidence: build and store/runmgr suites green with the aligned pin; check-pins green after this merge.

Impact: gitlink bump only.

Refs: core PR #26/#28 lineage.
2026-10-11 12:30:49 +00:00
weichen 840757240b Merge pull request 'Add execution ownership leases with epoch fencing and persistent run commands (U5)' (#151) from feature/collab-storage-u5-ownership into main
ci / vet-and-test (push) Successful in 1m5s
2026-10-11 12:26:35 +00:00
weichen 9422bf9c28 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 1m11s
2026-10-11 12:26:21 +00:00
weichen 603f1c0fa4 Add execution ownership leases with epoch fencing and persistent run commands (U5)
ci / vet-and-test (pull_request) Successful in 57s
Why: plan U5/KTD4+KTD5 - before a second instance may serve writes, conversation execution must have a single valid owner (DB lease, epoch increments on takeover), stale owners must not settle (in-transaction fence check), and cancel/HITL must reach the executing instance via a persistent command inbox.

Evidence: full suite green twice-relevant on SQLite and real PostgreSQL 18.6 (35 packages each tier) - lease tests (claim/contention/re-claim/renew fencing/expiry takeover/monotonic epoch, command inbox idempotent enqueue, atomic concurrent claim) and multi-instance tests (same-conversation contention rejected, stale-owner settle fenced with run unchanged, cross-instance cancel consumed exactly once by the owner instance, non-owner does not consume).

Impact: v23 migration (conversation_executions + run_commands); ClaimConversation stacks a DB lease on PG tiers (15min, release on conversation release); settle path fenced (fenced runs emit run.fenced, no terminal write); StartCommandConsumer polls the inbox for commands on locally-owned executions (cancel routes to local cascade or DB-level close-out; HITL revalidates gate and rejects expired explicitly); server cancel/HITL fall back to command enqueue with 202 when the run is not local; app wires instance identity (host:addr on PG tiers) and starts the consumer.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U5 KTD4/KTD5; R4/R10.
2026-10-11 12:25:31 +00:00
weichen 9f7b3c5405 Merge pull request 'Pin agent-system-core to 89f3906 (surface TS types and event union)' (#150) from feature/generative-ui-u4-coredep into main
ci / vet-and-test (push) Successful in 1m3s
2026-10-11 12:22:45 +00:00
weichen fadce52dce Pin agent-system-core to 89f3906 (surface TS types and event union)
ci / vet-and-test (pull_request) Successful in 1m2s
Why: Core #27/#28 landed the surface snapshot types and the
surface.updated union entry; the backend dep copy must match Dev's
modules/core pin or check-pins fails.

Evidence: scripts/check-pins.sh + combined make test in the Dev
workspace after re-pinning.

Impact: Gitlink bump only.

Refs: Core PR #27/#28; Dev task feature/generative-ui-u4
2026-10-11 12:22:41 +00:00
weichen 6abf112765 Merge pull request 'Add the generative-ui plugin, capability gating, and surface projection' (#149) from feature/generative-ui-u3 into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 10:38:27 +00:00
weichen 367dc207b3 Add the generative-ui plugin, capability gating, and surface projection
ci / vet-and-test (pull_request) Successful in 42s
Why: Generative UI U3 - the agent needs a real submission path and the
next turn needs to see prior pages: a built-in in-process plugin
registers generative-ui.present (exposeAsTool) carrying the catalog
(prompt=validation single source) with a schema-repair strategy
measured against real MiniMax-M3 deviations; the command bus gains an
owner-scoped tool gate so a disabled plugin stops injecting its tool
(execution guard remains in surfacesvc); ctxasm projects saved surfaces
back into context so the model can revise existing structure; doc-refs
are authorized against the owner's real artifacts.

Evidence: unit suites green (genui guards/epoch/doc-ref, gate fail-closed,
projection content); full go test ./... green; cmdbus/ctxasm also green
under AGENT_PG_TEST_DSN; real-model closed loop (env-gated
TestRealModelSurfaceLoop) passes: task->spec->save->projection->revise
with revision 1->2 and structure change, CNY 0.08 total.

Impact: New internal/genui package; additive cmdbus/ctxasm/runmgr/
runtime hooks; no storage or contract changes (U2 layer untouched).

Refs: Documents proposal r2 U3; Core PR #25/#26; Backend #143; Dev task
feature/generative-ui-u3
2026-10-11 10:38:15 +00:00
weichen 2bfebe378c Merge pull request 'Pin agent-system-core to the node-id fields merge (align with Dev core)' (#148) from feature/u6-core-pin into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 09:33:28 +00:00
weichen f8db2225bf Pin agent-system-core to the node-id fields merge (align with Dev core)
ci / vet-and-test (pull_request) Successful in 41s
Why: Dev top-level modules/core advanced to 7a98a7e7 (core PR #26, optional node id/source TS contract fields); check-pins requires the backend internal pin to match.

Evidence: go build and store/pgbus suites green with the bumped pin; check-pins green in the Dev worktree after this merge.

Impact: gitlink bump only (additive optional contract fields).

Refs: core PR #26.
2026-10-11 09:33:19 +00:00
weichen 8c113a70b8 Merge pull request 'Catch up event streams by cursor with real reconnect and periodic rescue (U6 slice 1)' (#147) from feature/collab-storage-u6-events into main
ci / vet-and-test (push) Successful in 42s
2026-10-11 09:30:30 +00:00
weichen fc75847580 Catch up event streams by cursor with real reconnect and periodic rescue (U6 slice 1)
ci / vet-and-test (pull_request) Successful in 43s
Why: plan section 2/KTD6 found the listener fetched only the last event of a batch (intermediate events invisible to live subscribers) and kept waiting on a dead connection after a drop (no actual reconnect).

Evidence: pgbus suite green twice against real PostgreSQL - batch of 64 all visible with continuous seq, 1200 events across pages, connection-kill reconnect recovers the outage window, no-notify events delivered by the periodic rescue; full backend suite green on both tiers.

Impact: Listener tracks per-conversation cursors (first-seen catch-up from 0 is harmless under SSE seq dedup), pages via EventsAfter to the notified watermark, rebuilds the connection with backoff and resumes known streams, and a rescue ticker covers lost notifications; Notifier contract unchanged.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U6 slice 1, KTD6; R5.
2026-10-11 09:30:29 +00:00
weichen 9ee87dcf2d Merge pull request 'Pin agent-system-core to 7a98a7e (TS contract authority for node ids)' (#146) from feature/generative-ui-u2-coredep into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 08:46:50 +00:00
weichen b302446986 Merge remote-tracking branch 'origin/main' into feature/generative-ui-u2-coredep
ci / vet-and-test (pull_request) Successful in 40s
2026-10-11 08:46:43 +00:00
weichen 5191efd891 Pin agent-system-core to 7a98a7e (TS contract authority for node ids)
ci / vet-and-test (pull_request) Successful in 39s
Why: Core PR #26 made ts/types.ts carry the node id/source fields; the
backend dep copy must match Dev's modules/core pin or check-pins fails.

Evidence: scripts/check-pins.sh in the Dev workspace; combined make
test rerun after re-pinning.

Impact: Gitlink bump only.

Refs: Core PR #26; Dev task feature/generative-ui-u2
2026-10-11 08:46:19 +00:00
weichen a466838440 Merge pull request 'Cover the surface tables in storage-migrate copy list' (#145) from feature/storage-migrate-new-tables into main
ci / vet-and-test (push) Successful in 37s
2026-10-11 08:33:01 +00:00
weichen 1d5672e4fe Cover the surface tables in storage-migrate's copy list
ci / vet-and-test (pull_request) Successful in 40s
Why: the copy-order coverage guard caught that backend PR #143's new tables (surfaces, surface_revisions, plugin_activation) were absent from the migration list - they would have been silently skipped on real migrations.

Evidence: TestCopyOrderCoversAllFixtureTables failed on the merged main and passes with the addition; full migrator suite green on SQLite and real PostgreSQL.

Impact: copyOrder list only; ordered after their referenced owners/projects.

Refs: backend PR #143; Dev U4 task.
2026-10-11 08:32:45 +00:00
weichen e3b1e0310e Merge pull request 'Add the storage-migrate tool: inventory, backup, and identity-governed migration (U4)' (#144) from feature/collab-storage-u4-migrator into main
ci / vet-and-test (push) Failing after 39s
2026-10-11 08:30:58 +00:00
weichen 40be71a3f1 Merge authoritative main into the task branch
ci / vet-and-test (pull_request) Failing after 41s
2026-10-11 08:30:53 +00:00
weichen 093b86fe7e Add the storage-migrate tool: inventory, backup, and identity-governed migration (U4)
ci / vet-and-test (pull_request) Successful in 38s
Why: plan U4/R7 - the SQLite-to-PostgreSQL path needs a managed entry that never replays SQLite dumps as PG SQL, fixes migration identity against a frozen snapshot, refuses foreign or non-empty targets, verifies per-table counts and ordered checksums, and keeps secrets out of reports (KTD14).

Evidence: integration tests against real PostgreSQL green twice (end-to-end with bool/NULL/Unicode/BLOB/sequence assertions, idempotent rerun, interrupted-table resume, foreign-data and wrong-snapshot refusals, unknown-owner block); SQLite unit tests for inventory/backup/coverage; full backend suite green on both tiers.

Impact: new cmd/storage-migrate binary (make build now emits bin/storage-migrate); store gains a DB() accessor documented for ops tooling; no server or runtime behavior change.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U4 and section 6; docs/guides/team-storage.md section 10.
2026-10-11 08:30:01 +00:00
weichen d79514302f Merge pull request 'Add the Surface store, activation guard, and EXT-17 host capability' (#143) from feature/generative-ui-u2 into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 08:27:02 +00:00
weichen 70983e19fe Merge remote-tracking branch 'origin/main' into feature/generative-ui-u2
ci / vet-and-test (pull_request) Successful in 40s
2026-10-11 08:26:55 +00:00
weichen 75829fa442 Add the Surface store, activation guard, and EXT-17 host capability
ci / vet-and-test (pull_request) Successful in 40s
Why: Generative UI proposal U2 needs the host side of the contract:
owner-isolated surface storage with revision CAS, an execution-level
enable/disable ledger (default-off, epoch bumped on every transition so
in-flight submits after a disable are rejected), a single-choke-point
service that validates specs (strict decode + catalog rules) before
saving, event publish with pending recovery, the optional SurfaceHost
extension capability, and direct API endpoints that share the same
guard (no bypass by entrance).

Evidence: go build/vet clean; full go test ./... passes (SQLite);
surface store tests also pass under AGENT_PG_TEST_DSN (PostgreSQL);
surface.txt regenerated via the AST snapshot test.

Impact: Additive migration v22 (new tables only); existing endpoints and
plugin_grants semantics untouched; old consumers unaffected. U3+ (data
binding, actions, frontend) deliberately out of scope.

Refs: Documents proposal 2026-10-10-generative-ui-plugin.md (r2 U2);
Core PR #25; Dev task feature/generative-ui-u2
2026-10-11 08:26:24 +00:00
weichen c8d66ca3cd Merge pull request 'Make notice redelivery iterative instead of self-recursive' (#142) from feature/notice-delivery-recursion-fix into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 08:12:14 +00:00
weichen 1ef1fdeac9 Make notice redelivery iterative instead of self-recursive
ci / vet-and-test (pull_request) Successful in 42s
Why: deliverNotices re-invoked itself synchronously from a defer after a failed startNoticeRun requeued the batch - a persistently failing delivery (busy conversation, missing row, slow DB) recursed without bound and overflowed the stack (real-PG CI job 2352, internal/runmgr).

Evidence: regression test TestNoticeDeliveryFailureIsBounded (missing conversation row) hangs on the old code and passes with the fix; full backend suite green on SQLite and against real PostgreSQL 18.6 + Redis; clean-HEAD reproduction isolated an unrelated count=2 admin-login artifact.

Impact: failed batches now return after requeue (retried on the next enqueue or restart); notices arriving during delivery are picked up by a bounded loop tail instead of defer recursion; no behavior change on the success path.

Refs: Dev PR #333; plan R8 real-database gate.
2026-10-11 08:12:03 +00:00
weichen 05c3447ebb Merge pull request 'Add shared execution budgets and the action-authz matrix (U3 slice 2)' (#141) from feature/collab-storage-u3-budget-authz into main
ci / vet-and-test (push) Successful in 38s
2026-10-11 05:18:20 +00:00
weichen f84733d128 Add shared execution budgets and the action-authz matrix (U3 slice 2)
ci / vet-and-test (pull_request) Successful in 43s
Why: plan U3 remainder (R8/KTD13) - execution admission must be bounded and shared (leases in the database, parents yield while waiting so budgets cannot deadlock parent-child), and project authorization must distinguish read/edit/approve/execute/admin with default-deny for unconfigured actions.

Evidence: full suite green on SQLite and against real PostgreSQL 18.6 + Redis (isolated instance); new tests - admission_pg_test (enforcement, per-user, concurrent no-oversell), runmgr admission tests (saturated-budget sync spawn progresses, background children share the budget, bounded queue 429), server action-matrix tests (default deny, legacy adapter read-only, action-aware authoritative + fail-closed).

Impact: admission_leases (v21) with atomic lease accounting under an advisory lock; runtime.execBudgetGlobal/PerUser/execQueueMax settings (all zero = off, historical behavior preserved); conversation turns, background and sync subagents share the pool, parents yield around synchronous children and HITL waits; queue-full rejects 429 before writing; server gains ProjectAction matrix + SetProjectActionAuthorizer with legacy EXT-8 read fallback.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 R8/KTD13; docs/guides/team-storage.md section 9.
2026-10-11 05:18:12 +00:00
weichen 3def525902 Merge pull request 'Close request atomicity: locked message append, card versions, atomic rate limit, recovery holds (U3 slice 1)' (#140) from feature/collab-storage-u3-atomicity into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 04:38:02 +00:00
weichen 5cd67be982 Close request atomicity: locked message append, card versions, atomic rate limit, recovery holds (U3 slice 1)
ci / vet-and-test (pull_request) Successful in 41s
Why: the approved plan U3 (KTD3/KTD7/KTD10 plus minimal recovery isolation) requires concurrent submits to not silently overwrite, unknown-side-effect runs to never auto-resume, and the shared rate limiter to degrade conservatively instead of failing open.

Evidence: full backend suite green twice-relevant paths against real PostgreSQL 18.6 and Redis (isolated instance) and on SQLite; new tests - card_version_test (concurrent single winner), recovery_hold_test + recovery_holds_test (skip/release/fail-closed CRUD), ratelimit atomic-window and degraded-fallback tests.

Impact: AppendMessageWithRequest now allocates seq under the conversation advisory lock in one transaction; activity_cards gain version (v19) with SaveCardVersioned/GetCardVersion/CardVersion and cardsvc user mutations (status/pin/action/resolve-by-ref) return 409-mapped conflicts while system mirrors keep blind writes; recovery_holds (v20) with admin list/release endpoints and fail-closed resume skip; Redis limiter uses one Lua INCR+PEXPIRE script and falls back to a per-instance limit with a Degraded counter.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 KTD3/KTD7/KTD10; R3/R6/R7/R8.
2026-10-11 04:37:50 +00:00
weichen f480c5b520 Merge pull request 'Retry transient SQLITE_BUSY at migration Begin and race on a materialized db' (#139) from feature/collab-storage-pg-tier-fix2 into main
ci / vet-and-test (push) Successful in 40s
2026-10-11 04:09:00 +00:00
weichen 6f20e6cc03 Retry transient SQLITE_BUSY at migration Begin and race on a materialized db
ci / vet-and-test (pull_request) Successful in 39s
Why: the connection-open phase (DSN pragmas incl. journal_mode on first open) can return SQLITE_BUSY immediately - the busy handler does not cover that phase - so four racing migrators failed in 0.03s on slow CI disks regardless of the 30s timeout (jobs 2293/2285).

Evidence: local -count=30 green (fast disk) vs CI deterministic failure at 0.03s (no timeout wait occurred); fix verified with -count=5 locally plus full store suite.

Impact: bounded retry (5 attempts, backoff) on Begin BUSY only - statements inside the IMMEDIATE transaction remain lock-protected; the racing test now materializes the file/WAL first, matching the KTD8 scenario (concurrent migrators on a provisioned database).

Refs: Dev PR #325; plan KTD8.
2026-10-11 04:08:50 +00:00
weichen efbd7b1d04 Merge pull request 'Raise the SQLite busy timeout to 30s for serialized migrators' (#138) from feature/collab-storage-pg-tier-fix1 into main
ci / vet-and-test (push) Failing after 40s
2026-10-11 03:42:55 +00:00
weichen 6f698d0ba5 Raise the SQLite busy timeout to 30s for serialized migrators
ci / vet-and-test (pull_request) Successful in 42s
Why: with _txlock=immediate the whole migration pass holds the write lock; four racing migrators on a slow CI disk exceeded the 5s busy timeout (job 2285: SQLITE_BUSY in TestMigrateRacingSQLite) although the local tier passes.

Evidence: go test -count=3 on the migration atomicity suite green locally; CI failure log cited in the Dev PR.

Impact: local SQLite tier only - lock contention waits up to 30s instead of erroring; no schema or API change.

Refs: Dev PR #325; plan KTD8.
2026-10-11 03:42:47 +00:00
weichen 2497508271 Merge pull request 'Add the team PG tier, atomic migrations and the connection budget (U2)' (#137) from feature/collab-storage-pg-tier into main
ci / vet-and-test (push) Successful in 41s
2026-10-11 03:11:12 +00:00
weichen 8633711b1d Add the team PG tier, atomic migrations and the connection budget (U2)
ci / vet-and-test (pull_request) Successful in 38s
Why: the approved collaboration plan U2 (KTD1/KTD8/KTD12) requires an explicit fail-closed team deployment tier, DDL-and-ledger-atomic migrations serialized across migrators, and a configurable PG connection budget with sanitized effective values.

Evidence: real PostgreSQL 18.6 + pgvector + Redis 8.10.2 local gate - full backend suite green twice with AGENT_PG_TEST_DSN/AGENT_REDIS_TEST_ADDR (isolated svc.sh instance collabtest:8897, redis 6399); SQLite suite green without env.

Impact: config tier+pool keys and validation; store PoolOptions + statement_timeout DSN param + single-transaction migration with PG advisory lock and SQLite _txlock=immediate + unknown-schema-version refusal; app tier guard fail-closed (team requires DSN+auth, no SQLite fallback); CLI flags and explain rows; tests for tier/pool parsing, atomicity fault-injection, racing migrators, old-schema upgrade; fixed pre-existing real-PG test defects (blind &search_path= appends in runmgr/pgbus helpers, pg_trgm per-schema install race, exhausted seq-conflict retries).

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U2, KTD1/KTD8/KTD12; R2/R3/R7.
2026-10-11 03:10:55 +00:00
weichen ea4651b1f8 Merge pull request 'Remove the empty -ws shell directory after each apply' (#136) from feature/agent-s0-workspace-overlay into main
ci / vet-and-test (push) Successful in 39s
2026-10-11 02:20:47 +00:00
weichen bd4b3bca94 Remove the empty -ws shell directory after each apply
ci / vet-and-test (pull_request) Successful in 40s
Why: relaxAndRemove cleared the per-exec id directory but left the -ws
parent behind, so every conversation accumulated an empty husk - the
live probe caught it in the workspace listing.

Evidence: go test ./... green; TestOverlayCleansShellDir pins the
behavior with a real bwrap roundtrip.

Impact: One extra os.Remove of the (now empty) parent; failures are
best-effort as before.

Refs: Dev task feature/agent-s0-workspace-overlay (fix-up of #135).
2026-10-11 02:18:01 +00:00
weichen 0c34b3595e Merge pull request 'Add the S0 task workspace overlay: frozen base, private upper, CAS apply' (#135) from feature/agent-s0-workspace-overlay into main
ci / vet-and-test (push) Successful in 41s
2026-10-11 02:14:36 +00:00
weichen 01fd3b6fd4 Add the S0 task workspace overlay: frozen base, private upper, CAS apply
ci / vet-and-test (pull_request) Successful in 43s
Why: The sandbox tier wrote the session directory directly, so a buggy
or runaway script could leave the workspace half-mutated and nothing
bounded what a timeout had already written. The plan's first landing
shape (section 4.4) swaps the session bind for a frozen-base overlay:
each exec freezes the session dir (hardlink clone + sha256 baseline),
runs on a private upper, and only a clean exit promotes the change set
back via CAS (baseline hash match -> atomic replace/remove; externally
changed targets double-store as *.conflict-* instead of clobbering).
Timeouts and kills discard the upper entirely - half-written state
never lands.

Evidence: go test ./... green including bwrap integration roundtrips
(TestSandboxWorkspaceOverlayRoundtrip: add/modify/delete through a real
overlayfs upper with whiteout handling and ws cleanup;
TestSandboxWorkspaceOverlayTimeoutDiscards: partial writes discarded on
timeout) and unit anchors for freeze manifest (.agentx excluded), CAS
add/modify, conflict double-store, and the change summary.

Impact: Opt-in via --exec-workspace overlay / AGENT_SYSTEM_EXEC_WORKSPACE
(only with --exec-backend sandbox; fail-closed on unknown modes). The
model sees a one-line change summary appended to the tool output, so
discards and conflict files are visible. Default behavior is unchanged.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md section
4.4; Dev task feature/agent-s0-workspace-overlay.
2026-10-11 02:04:05 +00:00
weichen eda90ed310 Merge pull request 'Log R4 reminder injection for observable evidence' (#134) from feature/r4-injection-log into main
ci / vet-and-test (push) Successful in 55s
2026-10-11 01:24:40 +00:00
weichen 5ebd1bb525 Log R4 reminder injection for observable evidence
ci / vet-and-test (pull_request) Successful in 56s
Why: Reminders are attached to the request copy and never reach the
session journal, so a transcript grep proves nothing about whether the
R4 trigger fired - the first verification attempt was invalid evidence.
The dual-speed nudge already logs for exactly this reason.

Evidence: go test ./internal/runtime green; the follow-up live probe
must show the [runtime] r4 escalation reminder injected line in the
instance log.

Impact: Two log lines, nothing else.

Refs: Dev task feature/agent-e4-escalation-ledger (third fix-up).
2026-10-11 01:22:49 +00:00
weichen 6647d4879c Merge pull request 'Switch the R4 failure trigger to a sliding window' (#133) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 42s
2026-10-10 18:44:09 +00:00
weichen 6709325c38 Switch the R4 failure trigger to a sliding window
ci / vet-and-test (pull_request) Successful in 40s
Why: The consecutive-failure counter never fired in the live suite (0
reminders despite 20 failures) because real failures interleave with
successes - each success reset the streak. The pattern to catch is
"spinning overall", not "N in a row".

Evidence: go test ./... green; the anchor test now covers scattered
failures (3 in the last 6 outcomes escalates, 2 does not, once per run);
the next live run must show the reminder actually appearing in the
transcript.

Impact: recentOutcomes ring (last 6) replaces consecToolFailures; the
zero-hit search trigger keeps its own threshold (2); message wording
matches the window semantics.

Refs: Dev task feature/agent-e4-escalation-ledger (second fix-up).
2026-10-10 18:43:03 +00:00
weichen ebec49db3d Merge pull request 'Restrict the source tag to material reads; never tag the model's own files' (#132) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 43s
2026-10-10 18:22:26 +00:00
weichen 03b4fdabaf Restrict the source tag to material reads; never tag the model's own files
ci / vet-and-test (pull_request) Successful in 42s
Why: The first E4 live comparison regressed badly (toolFailures 10->24,
toolCalls 63->166) and the instance transcript shows the loop: tagging
kernel read_file/grep results put a trailing [src_N] line on the
model's own workspace files, the model treated it as external
tampering, and re-read/re-edit cycles cascaded into the read-before-
write and file-changed guards.

Why: The ledger only ever needed SOURCES - searches always, and
workspace reads only under inputs/ (the upload material area). Grep
cannot distinguish materials from self-produced files, so it stays
untagged.

Evidence: go test ./... green; new anchor TestEvidenceLedger
OnlyTagsMaterialReads pins the inputs/-only rule; the re-run live
comparison follows on the Dev side.

Impact: evidenceTools loses read_file/grep; isEvidenceRead gates
workspace reads to inputs/; everything else (search tags, escalation
reminders, prompts) unchanged.

Refs: Dev task feature/agent-e4-escalation-ledger (fix-up of #131).
2026-10-10 18:11:24 +00:00
weichen d116eda0bc Merge pull request 'Add R4 escalation reminders and the structured source-id evidence ledger (E4)' (#131) from feature/agent-e4-escalation-ledger into main
ci / vet-and-test (push) Successful in 41s
2026-10-10 17:50:21 +00:00
weichen 21b8461525 Add R4 escalation reminders and the structured source-id evidence ledger (E4)
ci / vet-and-test (pull_request) Successful in 43s
Why: The plan's E4 remainder under the MiniMax-only constraint: the
harness must detect the model going in circles and inject a
change-of-strategy instruction (R4 - the model cannot be trusted to
notice its own repeat-fail loop), and number sourcing needs addressable
evidence: tool results carry [src_N|tool] tags so "found in materials"
becomes "found in source src_N" for both the model and the fact-check
notice.

Why: Failure counting lives in one place (onEvent end, keyed by the
slot status) because tool execution is parallel; the ledger and tag
append are serialized by the same mutex, and escalation reminders fire
once per condition streak - following the dual-speed nudge finding that
MiniMax ignores vague reminders but follows concrete instructions.

Evidence: go test ./... green; new anchors TestEscalationOnConsecutive
ToolFailures, TestEscalationOnZeroHitSearches, TestEvidenceLedger
TagsSource, TestEvidenceLedgerSkipsNonEvidenceTools. Live before/
after comparison runs on the Dev side per ruling 6.

Impact: knowledge/web/tavily searches, fs_read_file and the workspace
read_file/grep results now end with a source tag and enter the run
ledger (2k-char head); two consecutive tool failures or two empty
search results inject a concrete change-of-strategy reminder;
base.deliverables v7 and agent-sub-research v3 explain the tags;
fact-check notice points at src results. Non-evidence tools and
failures are unchanged.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E4,
R4); Dev task feature/agent-e4-escalation-ledger.
2026-10-10 17:41:38 +00:00
weichen bd687d27d4 Merge pull request 'Add estimate-marking awareness and a second fact-check round (E3)' (#130) from feature/agent-e3-evidence-gate into main
ci / vet-and-test (push) Successful in 45s
2026-10-10 16:10:42 +00:00
weichen 06f5835211 Add estimate-marking awareness and a second fact-check round (E3)
ci / vet-and-test (pull_request) Successful in 41s
Why: The E1 live comparison was the only metric that got worse
(unverifiedFacts 7 -> 12): the gate gave one correction round and then
shipped bare numbers. The design (E3) calls for number sourcing with a
mechanically checkable marking escape: numbers labeled as
targets/assumptions are honest planning quantities, not fabricated
facts, so the gate must let them pass - and the model must be told that
marking is the sanctioned way out.

Why: The marking decision is clause-scoped, not a fixed window - a
window wrongly blessed "3,500" because a neighboring clause said
"target 85%" (caught by the existing test). The second round only
fires when the unverified set actually changed, because the
continuation budget is shared across all final gates and a repeat
block on identical tokens starves the downstream gates (also caught by
an existing test).

Evidence: go test ./... green; new anchors TestMarkedAsEstimate,
TestUnverifiedFactsMarksPass, TestFactCheckGateRounds; the live eval
gains an unverifiedUnmarkedFacts field sharing the same marking rule.

Impact: base.deliverables v6 adds the hard number-sourcing rule;
research/explore/general sub-agent profiles v+1 require citations for
key numbers; fact gate: marked-in-clause numbers pass, round 2
escalates to "last interception" with the marking instruction, then
passes through with the check line. Eval report keeps the strict
series and adds the E3-series field.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E3,
D4); Dev task feature/agent-e3-evidence-gate.
2026-10-10 16:08:19 +00:00
weichen 017b40f761 Merge pull request 'Bump the agent-squad pin (E1 chain)' (#129) from feature/bump-squad into main
ci / vet-and-test (push) Successful in 42s
2026-10-10 10:12:30 +00:00
weichen 288e4763a3 Merge authoritative main into the bump branch
ci / vet-and-test (pull_request) Successful in 40s
2026-10-10 10:11:07 +00:00
weichen 5714c739de Merge pull request 'Wire the dual-speed lane into the real run path: entry hint, escalating nudge, think-stripped cards' (#128) from feature/dual-speed-real-path into main
ci / vet-and-test (push) Successful in 41s
2026-10-10 10:09:51 +00:00
weichen 7ef6ef86cd Bump the agent-squad pin for the E1 chain
ci / vet-and-test (pull_request) Successful in 42s
2026-10-10 10:09:01 +00:00
weichen c449a176f9 Restore the E1 agent-native kernel pin c348441 over the stale submodule state captured by the merge
ci / vet-and-test (pull_request) Successful in 43s
2026-10-10 10:06:55 +00:00
weichen 8355794e45 Merge remote-tracking branch 'origin/main' into feature/dual-speed-real-path
# Conflicts:
#	internal/runtime/llm_direct.go
#	internal/runtime/llm_test.go
2026-10-10 10:05:38 +00:00
weichen b3f885f215 Strip inline think blocks from subagent summaries and card bodies
ci / vet-and-test (pull_request) Successful in 43s
Why: the round-6 real-model dual-speed run showed MiniMax writing its
reasoning as inline <think> blocks; finishChild's partsText fed them into
the background notice turn and the inbox result card verbatim.
Evidence: new runmgr test covers closed-block stripping with body preserved
and conservative handling of unclosed blocks; go test ./... 32 pkgs green.
Impact: partsText removes closed <think>...</think> spans before summary,
notice, and card assembly; unclosed blocks are kept as-is.
Refs: Dev task feature/dual-speed-real-path.
2026-10-10 10:01:15 +00:00
weichen 3f2c7c7033 Nudge long-running turns onto the dual-speed path via the kernel reminder hook
Why: the dual-speed slow lane shipped as an observable capability plus soft
prompt guidance, but on the real (non-mock) path models routinely grind
through heavy document tasks synchronously for minutes with zero user-visible
output - the pattern only engages when the model happens to think of it.
Evidence: five new unit tests over an injected clock cover fire-after-threshold,
quiet-before-threshold, disabled-by-setting, and the replied/delegated guard
paths (reminders observed in the captured kernel requests); full go test
./... 32 pkgs green. Real-model run recorded in the Dev task card.
Impact: runDirect tracks run start, user-reply, and background-delegation
state; reminders() injects a one-shot dual-speed nudge (SR-B10) once elapsed
time crosses runtime.dualSpeedNudgeSeconds (default 75s, 0 disables) while
the user has neither a reply nor a background lane. New setting registered.
Refs: Dev task feature/dual-speed-real-path; SR-B10 (RDM requirements/01 V2.1).
2026-10-10 09:11:18 +00:00
weichen eef60ff0b9 Merge pull request 'Enable parallel tool execution, fix delegate truncation, sandbox deps layer (E1)' (#127) from feature/agent-e1-parallel-fixes into main
ci / vet-and-test (push) Successful in 46s
2026-10-10 09:04:57 +00:00
weichen 4df81ef1da Enable parallel tool execution, fix delegate truncation, sandbox deps layer
ci / vet-and-test (pull_request) Successful in 43s
Why: Real runs were fully serial: the direct path never set the kernel's
parallel tool mode, delegate_task handed the parent only the child's
160-char summary (the full text was dropped), MiniMax streamed its
reasoning inside content <think> blocks, and the EXT-16 sandbox hid
~/.local so python-docx/openpyxl/pptx could not even import. All four
were measured defects (F1/F2/F3/F5) in the delegation/routing proposal.

Why: Serial-only mutual exclusion is not enough for order-sensitive
tools - the card-stream test failed with out-of-order appends - so the
ordering moved into the kernel (SerialTools partition, Agent-Native
#44); process events pair by CallID (#42) and tool results write back
through the injected call context (#43).

Evidence: go test ./... green, including new regression anchors:
TestDelegateToolReturnsFullText, TestToolEventsPairByCallID,
TestToolExecModeAndSerialSet, TestOpenLLMClientReasoningSplitAuto,
TestSandboxArgvDepsLayer, TestExecEnvJoinsPythonPath.

Impact: Direct runs now execute independent same-turn calls
(searches, reads, delegate_task, review_findings) concurrently by
default (runtime.parallelTools=false restores sequential); card flow,
plans, workflows, exec primitives and file edits keep model-declared
order. delegate_task returns the capped full child text. MiniMax-*
models get reasoning_split=true so thinking streams via
reasoning_content. --exec-deps-dir/AGENT_SYSTEM_EXEC_DEPS_DIR mounts a
read-only dependency layer into the sandbox and merges PYTHONPATH.
Bumps deps/agent-native to c348441.

Refs: proposal 2026-10-10-agent-delegation-routing-sandbox.md (E1,
F1-F5); Agent-Native #42/#43/#44; Dev task feature/agent-e1-parallel-fixes.
2026-10-10 09:02:10 +00:00
weichen c6912b63e7 Merge pull request 'Keep the final reply clean when checks continue or the verifier is skipped' (#126) from feature/final-reply-pollution-fix into main
ci / vet-and-test (push) Successful in 43s
2026-10-10 07:56:41 +00:00
weichen 18aa99cb95 Keep the final reply clean when checks continue or the verifier is skipped
ci / vet-and-test (pull_request) Successful in 1m1s
Why: real run run_b520bf2e3037 showed the user-facing reply being displaced by
internal bookkeeping - the fact-check correction round's closing text plus the
same-source verifier skip notice became the visible answer while the actual
summary was folded into the process timeline (frontend splits at the last
process part). Same tail pollution reproduced on every same-model run since.
Evidence: go test ./... -count=1 green (32 pkgs); new regression tests cover
the demotion of the pre-check summary and the skip notice becoming a check
part; three old-contract tests updated to assert notices land as process
checks instead of final text.
Impact: stop() fact-check branch now demotes the standing summary (aligned
with the gate and cap branches) and asks the model for a fresh full reply;
verifier errors record as process checks (pass-state for same-source skip,
fail-state for real errors) instead of StopDecision.Notice text; the cap
notice likewise becomes a failed check line.
Refs: Dev task feature/final-reply-pollution-fix; diagnosis in
.task/feature-dual-speed-tasks.md lineage and session analysis 2026-10-10.
2026-10-10 07:48:03 +00:00
weichen d2bb936784 Merge pull request 'Make background subagents observable as the slow lane of dual-speed tasks' (#125) from feature/dual-speed-tasks into main
ci / vet-and-test (push) Successful in 44s
2026-10-10 06:52:50 +00:00
weichen 43087a79f8 Make background subagents observable as the slow lane of dual-speed tasks
ci / vet-and-test (pull_request) Successful in 45s
Why: SR-B10 (RDM requirements/01 V2.1, ruling J-03) defines the task-level
dual-speed pattern: a fast lane gives immediate feedback while deep work runs
in background with queryable progress and results posted back to the inbox.
The base system had the background spawn/notice machinery but nothing the
user could see while it ran or collect afterwards, so heavy tasks just made
the user wait on a synchronous turn.
Evidence: go test ./... -count=1 green (32 pkgs); new runmgr tests cover the
card lifecycle on success (progress card -> done/ok with result summary and
artifact line, card.created/updated/status events) and on failure
(done/danger with the error), plus the cards==nil no-op path.
Impact: spawnBackground now registers an inbox progress card linked to the
child run and rewrites it with the result on completion; the delegate_task
description and base-method working prompt teach the fast-feedback-first
pattern; a #background mock demo exercises the whole path end to end.
Refs: RDM docs/requirements/01 UR-27/SR-B10; .tmp 03-V2 §任务快慢双速通用模式;
Dev task feature/dual-speed-tasks.
2026-10-10 06:40:00 +00:00
weichen 421fee81c3 Merge pull request 'Let in-proc plugins register the EXT-8 resource authorizer (fix rdm.* unknown-command)' (#124) from feature/ext8-inproc-authorizer into main
ci / vet-and-test (push) Successful in 41s
2026-10-10 02:08:04 +00:00
weichen 72b2098030 Let in-proc plugins register the EXT-8 resource authorizer (fix rdm.* unknown-command)
ci / vet-and-test (pull_request) Successful in 42s
Why: pluginRegistry never overrode RegisterResourceAuthorizer, so
in-proc extensions hit the declarative-branch rejection; register()
aborted wholesale and every plugin command vanished from the bus. The
RDM binary ships the EXT-8 member adapter since step-4, meaning the
served RDM process answers 'unknown or disabled command' for every
rdm.* call — caught by the MS1 re-acceptance smoke replay (fb/report/
confirm/lifecycle smokes all failed on rdm.baseline).

Evidence: TestInprocPluginRegistersAuthorizerAndCommands — red on the
parent commit (registration aborts, dispatch rejected, nil-slot denies),
green with the fix (command committed; the shared authz slot answers
alice-allow/bob-deny through projectAuthz). Full go test ./... green;
RDM MS0 acceptance re-run follows on the pin bump.

Impact: pluginRegistry.RegisterResourceAuthorizer fills the shared
hostcaps.authz slot (first registrant wins, mirroring the composition
root); compiled/declarative branch unchanged.

Refs: RDM G1-8 MS1 re-acceptance; EXT-8 (d89e748 seam); RDM #113.
2026-10-10 02:06:12 +00:00
weichen e03d3ac6c6 Merge pull request 'Narrow the blocking gate to hard facts and add fact-consistency checks (quality P5)' (#123) from feature/agent-quality-phase05 into main
ci / vet-and-test (push) Successful in 41s
2026-10-10 00:34:26 +00:00
weichen c802be812d Merge origin/main into feature/agent-quality-phase05
ci / vet-and-test (pull_request) Successful in 40s
2026-10-10 00:30:05 +00:00
weichen 9a531a61d9 Merge pull request 'Wire plugin slash aliases and the intent declaration surface (G2-2)' (#122) from feature/g2-2-slash-intents into main
ci / vet-and-test (push) Successful in 40s
2026-10-10 00:20:13 +00:00
weichen 9c4edc4dc9 Update the extension surface snapshot for AddIntent and IntentSpec
ci / vet-and-test (pull_request) Successful in 40s
Why: TestSurfaceSnapshot compares the exported-symbol list verbatim;
Registry.AddIntent and IntentSpec (SurfaceVersion 2.7.0-experimental)
are intentional additions.

Evidence: pkg/extension suite green; full go test ./... 32/32 packages
green.

Impact: surface.txt only.
2026-10-10 00:18:32 +00:00
weichen 9786dc1456 Wire plugin slash aliases and the intent declaration surface (G2-2)
Why: plan G2-2 requires plugin commands reachable via slash aliases
through the same bus and audit envelope (EXT-10②), and a natural-
language routing entry so '帮我写项目建议书' can reach the compile
flow. The agent-command lib now carries a validated Slash field
(lib PR #6, pin bumped to 4f583ca); the bus binds plugin-spec aliases
into the same slash table; intents are declared via the extension
registry and routed deterministically (normalized containment, longest
phrase first) to commands through the bus — the system.intent builtin
(/intent) is the chat-entrance dispatch point with a candidate list on
miss.

Evidence: TestPluginSlashAliasBindsAndDispatches — alias binds, case/
whitespace-normalized resolution, slash and button entrances hit the
same command handler with committed receipts; TestIntentDeclareMatch
Route — declare/validate, miss on unrelated text, normalized hit,
bus-dispatch through the chat entrance, longest-phrase priority;
server suite green with the dual-entry pair registered; full go test
./... 31/31 packages green.

Impact: cmdbus plugin-spec slash binding + IntentStore; extension
Registry.AddIntent + IntentSpec (SurfaceVersion 2.7.0-experimental);
pluginRegistry.AddIntent wires plugin-declared intents; server
builtin system.intent; command-pairs fixture extended.

Refs: plan G2-2 (line 366); EXT-10②; lib PR #6 (4f583ca).
2026-10-10 00:15:21 +00:00
weichen ef060d4ee5 Keep only hard facts in the blocking gate and add fact-consistency checks
ci / vet-and-test (pull_request) Successful in 40s
Why: User rulings 4/5 plus the P2-P4 live evidence. Keyword scoring dimensions
(readability, actionability) kept failing valid documents - normal Chinese
section headings flagged as column titles, loops of 4 corrections - because
they grade writing preferences, not verifiable facts. Unverified numbers
(~10-13 per run) remained the largest quality leak through every phase.
And the verifier runs on the same model as the worker (lightweight falls back
to chat), so "independent" verification was self-review. Separately, svc
seeding after boot never registered the tavily tools until a restart because
keys were snapshotted at startup.

Changes: quality.HardFacts (lint actually ran + zero errors) replaces the
full ScoreBytes projection in CheckArtifacts - keyword dimensions remain in
ScoreBytes for eval visibility only. New fact-consistency check
(runtime.factCheck, default on): extract percentages/dates/large numbers from
deliverable text, trace each against the material corpus (attachment text +
tool evidence); misses get exactly one correction round (fix, drop, or mark
as target/estimate), never a loop. runVerifier skips same-source model
verification unless runtime.verifierSameModel is set (hard facts + fact check
still run). Search tools now register lazily, reading tavily keys per call
(settings/seeding take effect immediately).

Evidence: SELF - TestHardFactsIgnoresKeywordDimensions,
TestHardFactsKeepsLintUnavailableBlocking, TestUnverifiedFactsAndNotice,
TestFactCheckContinuationOnce (fact note fires, corrected run passes);
go vet + go test ./... pass; live before/after on the golden task set
recorded on the phase05 task card per the policy clause.

Impact: Gate stops rejecting valid documents on stylistic grounds; invented
figures get one forced correction round; same-model verification no longer
pretends to be independent; tavily config is hot. No contract or storage
changes.
2026-10-10 00:04:57 +00:00
weichen 1a3fbfb3d8 Merge pull request 'Add the working-method prompt set and demote the compose rewrite (quality P4)' (#121) from feature/agent-quality-phase04 into main
ci / vet-and-test (push) Successful in 41s
2026-10-09 23:40:29 +00:00
weichen 6be8980c17 Add the working-method base prompt and demote the compose rewrite
ci / vet-and-test (pull_request) Successful in 38s
Why: User rulings 4 plus the P3 live evidence. The base library taught layout
standards but not how to work: no planning discipline (plan tool used in 2 of
31 tool runs), no retry-escalation rule (same failing call retried verbatim),
no check-API-first rule (phantom API calls), and nothing about edit
granularity - the P3 after-run showed one turn of 27 single-line edits plus a
self-review spiral (117 tools). The final-reply rewrite layer sees only
160-char summaries and fabricated chapters/promised unfinished work; with the
transcript now authoritative the working model already has full evidence, so
per ruling 4 the rewrite defaults off (kept as fallback when a work loop ends
with no user-facing text at all, plus opt-in setting).

Changes: new base.method module (plan-first for 3+ steps, switch approach
after two failures, check API signatures before scripting, batch edits with
multi_edit, persist to completion - never end on a promise, trace numbers/
dates/names back to source material or mark speculation, honest final
report); tool-description overrides for read_file/edit_file/grep/glob/
run_python/run_shell (dedicated tools over cat/sed, build pipeline for Word);
plan reminder now also nudges once when NO plan exists after several tool
turns (ignorable, once per run); runtime.composeFinal default true->false
with empty-body fallback.

Evidence: SELF - TestPlanlessNudgeFiresOnce (fires once, never repeats),
TestResolveSystemDefaultBase updated for the ninth base block; go vet +
go test ./... pass; live before/after on the golden task set recorded on the
phase04 task card per the policy clause.

Impact: Behavior-surface change only; no contract or storage changes.
2026-10-09 23:12:53 +00:00
weichen 5b9406dd91 Merge pull request 'Open kernel workspace tools and the markdown-source build pipeline (quality P3)' (#120) from feature/agent-quality-phase03 into main
ci / vet-and-test (push) Successful in 43s
2026-10-09 18:29:38 +00:00
weichen 77da63edac Open the kernel workspace tools and the markdown-source build pipeline
ci / vet-and-test (pull_request) Successful in 41s
Why: User rulings 1 and 3. The direct conversation path exposed only
run_shell/run_python, so any file edit meant rewriting and re-running whole
generation scripts (diagnosis: 24 calls with 9 failures in one turn, 6KB
scripts regenerated 8 times). The kernel already ships line-numbered reads,
exact edit/multi_edit with read-before-write, grep and glob; they were wired
only into the squad path. Deliverables had no editable source: docx output
came from one-shot python scripts, so "make section 3 more detailed" rebuilt
everything.

Now: WorkspaceToolsCheckpointed (sandbox = conversation workspace, checkpoint
hooks wired) is registered into the direct run and its decls join the tool
surface (plan mode keeps the read-only subset). Word deliverables get a
markdown-source pipeline - python3 -m housestyle.build renders .md
deterministically via docx_kit and runs lint + outline on every build
(write-time diagnostics, exit 1 on error so the model fixes the source and
rebuilds). run_python kits remain for xlsx/pptx/images (ruling 3).
base-tools/base-deliverables prompts and the house-style SKILL document the
new loop.

Evidence: SELF - TestDirectRunExposesKernelWorkspaceTools (read->edit->grep
against the real sandbox FS), TestDirectRunWithoutSandboxHidesKernelTools,
three new BuildTests in recipes (renders+lint passes, heading-skip errors
exit 1, CLI entry); go vet + go test ./... pass; live before/after on the
golden task set recorded on the phase03 task card per the policy clause.

Impact: Direct conversations can now read and edit workspace files
surgically; docx deliverables become edit-source + rebuild instead of
regenerate. No contract or storage changes.
2026-10-09 18:01:06 +00:00
weichen aa8eaf3f01 Merge pull request 'Align the nested agent-squad pin with the Dev top-level pointer' (#118) from feature/agent-quality-phase02 into main
ci / vet-and-test (push) Successful in 40s
2026-10-09 17:10:06 +00:00
weichen 52775178dc Merge origin/main into feature/agent-quality-phase02
ci / vet-and-test (pull_request) Successful in 41s
2026-10-09 17:05:34 +00:00
weichen 1a550895c3 Merge pull request 'Add the EXT-16 exec backend interface: direct status quo and bwrap sandbox' (#119) from feature/ext-16-exec-backend into main
ci / vet-and-test (push) Successful in 41s
2026-10-09 17:02:57 +00:00
weichen 4badecbf57 Add the EXT-16 exec backend interface: direct status quo and bwrap sandbox
ci / vet-and-test (pull_request) Successful in 41s
Why: Q2 ruled exec tools stay disabled for now but code execution must
return later; the recovery path treats model-generated code as
untrusted. This abstracts the interpreter launch point in
exec_interpreter.go into an ExecBackend port (direct = byte-identical
status quo; sandbox = bwrap isolation) so downstream products can
re-enable execution behind isolation without further base surgery.

Evidence: go build/vet clean; go test ./... 32/32 packages green;
sandbox escape negatives all blocked locally (bwrap 0.11.1): home
secret unreadable (tmpfs /home), sibling session unreadable
(session-only bind), network blocked (--unshare-net), 400-process
spawn storm capped by ulimit -u 256 (fork: retry), 60s sleep killed at
3s timeout, orphans die with the namespace; session-dir write visible
on host; direct backend regression (exit=7 / exit=timeout texts)
unchanged.

Impact: run_shell/run_python route through the backend; workflow
ScriptCommand and the squad kernel ExecConfig stay direct (follow-up
scope); config key execBackend + env AGENT_SYSTEM_EXEC_BACKEND + CLI
--exec-backend; extension.Options.ExecBackend (SurfaceVersion
2.6.0-experimental); DisableExecTools remains the top-priority
fail-closed switch.

Refs: proposal 2026-10-09-ext-g1-b1-b3-exec-backend.md section 4
(EXT-16, G2-8 prototype); Backend #110 (DisableExecTools).
2026-10-09 17:01:32 +00:00
weichen eb697ab1c0 Merge origin/main into feature/agent-quality-phase02
ci / vet-and-test (pull_request) Successful in 41s
2026-10-09 17:00:09 +00:00
weichen db5efc9b83 Align the nested agent-squad pin with the Dev top-level pointer
ci / vet-and-test (pull_request) Successful in 40s
Why: Dev's test-pins requires nested library pins to match Dev's top-level
gitlinks; Dev moved agent-squad to 8bbf850 (nested native pin alignment,
Agent-Squad #49) for the quality P2-I2 combination.

Evidence: SELF - 8bbf850 is Agent-Squad main head (#49 merged); combined
make test recorded on the Dev phase02 task card.

Impact: Pin only.
2026-10-09 16:54:48 +00:00
weichen 31965fd837 Merge pull request 'Point the agent-squad pin at the nested native-pin bump (Squad #49)' (#117) from feature/squad-pin-8bbf850 into main
ci / vet-and-test (push) Successful in 40s
2026-10-09 16:40:38 +00:00
weichen 5544a0304d Point the agent-squad pin at the nested native-pin bump (Squad #49)
ci / vet-and-test (pull_request) Successful in 40s
Why: Dev umbrella pin consistency requires backend's agent-squad gitlink
to match Dev's; squad main moved to 8bbf850 (nested agent-native pin at
3b92cb63, same as backend's own native pin after #115), so backend must
carry it before Dev can consume 8157e68+.

Evidence: gitlink-only change; Squad #49 merge 8bbf850 records
deps/agent-native=3b92cb63 (verified via ls-tree).

Impact: nested gitlink only; no backend source change.

Refs: Squad PR #49; Dev check-pins cascade; Backend #115/#116.
2026-10-09 16:39:21 +00:00
weichen 8157e68cf1 Merge pull request 'Decouple runmgr from cmdbus via view types in CommandSource (EXT-14 lint fix)' (#116) from feature/ext14-cmdsource-view into main
ci / vet-and-test (push) Successful in 42s
2026-10-09 16:33:23 +00:00
weichen 6337cd87f8 Decouple runmgr from cmdbus via view types in CommandSource
ci / vet-and-test (pull_request) Successful in 40s
Why: the EXT-14 wiring made runmgr import cmdbus for the CommandSource
interface signatures, which adds a new edge backend/runmgr ->
backend/cmdbus outside the architecture baseline ratchet; the Dev
umbrella lint (lint-architecture) rejects the gitlink bump because of
it. The dependency direction was always meant to be inverted: runmgr
defines the port, the composition root adapts the bus.

Evidence: go build/vet clean; go test ./... 32/32 packages green;
grep confirms no internal/cmdbus import left in non-test runmgr files;
Dev-side lint re-run scheduled on the gitlink bump PR.

Impact: runmgr.CommandTool/CommandRequest/CommandResponse are local
view types; app.busAdapter now converts cmdbus types at the boundary
(Entrance still fixed to agent by runmgr). No behavior change; the
runmgr tests speak the view types instead of cmdbus ones.

Refs: Dev umbrella make test failure 'architecture: new dependency
edges backend/runmgr backend/cmdbus'; proposal 2026-10-09-ext-g1-b1-b3
(EXT-14); Backend #114.
2026-10-09 16:31:43 +00:00
weichen 822cda2833 Merge pull request 'Make the kernel transcript the authoritative cross-turn context (quality P2-I2)' (#115) from feature/agent-quality-phase02 into main
ci / vet-and-test (push) Successful in 41s
2026-10-09 16:26:51 +00:00
weichen 8c17a2228b Merge origin/main into feature/agent-quality-phase02
ci / vet-and-test (pull_request) Successful in 42s
2026-10-09 16:25:22 +00:00
weichen 545dc404e5 Merge pull request 'Implement EXT-14: commands-as-tools dispatched through the command bus' (#114) from feature/ext-13-15-shared-runs into main
ci / vet-and-test (push) Successful in 43s
2026-10-09 16:19:11 +00:00
weichen 81da190ac1 Merge origin/main into feature/agent-quality-phase02
ci / vet-and-test (pull_request) Successful in 42s
2026-10-09 16:18:24 +00:00
weichen 020926234e Expose commands as in-run tools dispatched through the command bus (EXT-14)
ci / vet-and-test (pull_request) Successful in 43s
Why: RDM's capabilities are rdm.* commands; the agent run has no bridge
to them, so the V0.1 end-to-end goal (full-conversation operation) and
the agent-driven two-trees-one-net access are unreachable (Q4 option A).
Evidence: SELF - red tests first: TestCommandToolsProjected and
TestCommandToolDispatchesThroughBus failed on undefined symbols, then
green; TestCommandToolsFiltering and TestCommandToolDispatchEnvelope
cover projection filtering (risk default low, description falls back to
title, unexposed commands absent) and the full dispatch receipt with
Entrance=agent preserved through CanonicalEntrance; go test ./... 32/32
packages ok.
Impact: agent-command pin cc1ad77 -> 5c6dd0c (library PRs #4/#5: optional
ExposeAsTool field + "agent" entrance). cmdbus gains CommandTool view and
Bus.CommandTools; runmgr gains SetCommandSource with projection into
runCtx.Tools (dedup: registry tools win) and routing in ExecuteTool -
model calls dispatch through the bus with Entrance=agent, Actor/Owner =
conversation owner, ConversationID set; high-danger commands keep the
inline-confirm-card flow via the bus; unregistered source = current
registry behavior. Composition root wires a busAdapter. No
pkg/extension change.
Refs: proposal 2026-10-09-ext-g1-b1-b3 section 2; Agent-Command #4/#5
2026-10-09 16:14:57 +00:00
weichen 9fa1787d01 Merge pull request 'Implement EXT-13/15: shared retrieval inside runs and shared project listing/conversations' (#113) from feature/ext-13-15-shared-runs into main
ci / vet-and-test (push) Successful in 41s
2026-10-09 15:49:33 +00:00
weichen 59f62cace1 Nudge CI: TestToolCallDeltaBeforeExecution ordering flake (passes 8/8 locally)
ci / vet-and-test (pull_request) Successful in 41s
2026-10-09 15:47:36 +00:00
weichen 439865616a Share retrieval inside runs and open project listing/conversations to members (EXT-13/15)
ci / vet-and-test (pull_request) Failing after 44s
Why: A member's agent cannot see published project knowledge inside a run
(runCtx.Retrieve and hostcaps.search are owner-only), and members can
neither see shared projects nor open conversations in them - both are P0
prerequisites for RDM G1 (EXT-13/EXT-15, proposal
2026-10-09-ext-g1-b1-b3-exec-backend).
Evidence: Red tests first - TestRunContextRetrieveShared (undefined
SetSharedKnowledgeAllow), TestInprocHostKnowledgeSearchShared (0 shared
hits), TestSharedProjectListAndConversation (empty list) all failed before
and pass after; go test ./... 32/32 packages ok.
Impact: runmgr gains SetSharedKnowledgeAllow and routes Retrieve through
RetrieveShared when the predicate is registered (nil = unchanged); hostcaps
search consults the same EXT-8 slot; GET /api/projects appends
adapter-authorized shared projects (projectView with shared flag, deduped);
POST /api/projects/{id}/conversations falls back to
CreateConversationInProject when the own-project path misses and the
adapter authorizes; store gains ListAllProjects (candidates only, callers
must filter through the adapter) and CreateConversationInProject.
Revocation takes effect immediately on both paths; absence of an adapter
is byte-for-byte the previous behavior. No pkg/extension surface change.
Refs: docs/proposals/2026-10-09-ext-g1-b1-b3-exec-backend.md (EXT-13, EXT-15)
2026-10-09 15:44:48 +00:00
weichen 4675a25c18 Make the kernel transcript the authoritative cross-turn context
Why: Every turn rebuilt context from UI messages with 160-char tool-result
summaries and truncated arguments (quality diagnosis root cause 1). The kernel
session transcript already persists full-fidelity messages; P2-I2 switches the
direct path to it as the authority, per the approved plan (Documents
2026-10-09 §4 P2, user ruling 2).

First run (or after structural divergence) seeds the transcript once with the
full ctxasm assembly; later runs pass only the current user message. Dynamic
context - tail notes (personal instructions, session notes, world-book
entries), env update (date, workspace files) and newly hit task-layer prompt
modules - refreshes once per non-seed run via Reminders, so the frozen seed
system prompt stays accurate. Structural divergence (excluded messages,
variant switching, regeneration) changes a fingerprint and reseeds the
transcript from the message store - the regenerate/exclude/variant mapping.
Legacy transcripts without a fingerprint are adopted, not wiped, preserving
crash-pairing repair. Near the window threshold a 9-section compaction
(aux.session-compact, extractive fallback) summarizes older turns with the
kernel's append-only compaction entry; the kernel request-side micro-clear
still bounds within-run spikes. Backend's nested agent-native pin moves to
the reasoning-capture/replay kernel head.

Evidence: SELF - new tests TestContextRevisionStableOnAppendAndChangesOnDivergence,
TestTranscriptReseedsOnVariantSwitch, TestBuildCurrentUserCarriesAttachmentsAndKnowledge;
TestDirectRunSeedsStoreHistoryOnce updated to authoritative semantics; go vet
+ go test ./... pass; live before/after on the golden task set recorded on
the phase02 task card per the policy clause.

Impact: Direct conversations with a workspace sandbox now carry full tool
results, arguments and attachment text across turns; MiniMax thinking replays
from the transcript via the kernel change. No-conversation fallback keeps the
pre-I2 assembly path.
2026-10-09 15:38:33 +00:00
weichen 449d8a6943 Merge pull request 'Keep original materials and cleared results reachable across turns (quality P2-I1)' (#112) from feature/agent-quality-phase02 into main
ci / vet-and-test (push) Successful in 43s
2026-10-09 15:22:46 +00:00
weichen 9fe2a592f3 Merge origin/main into feature/agent-quality-phase02
ci / vet-and-test (pull_request) Successful in 43s
2026-10-09 15:12:16 +00:00
weichen 908fb253d7 Keep tool-result dumps out of the deliverable gate and cards
ci / vet-and-test (pull_request) Successful in 45s
Why: The live after-run (golden minutes-pm-tool) showed the truncation spill
artifact tool-run_shell-<call>.txt entering CheckArtifacts as a .txt
deliverable; its non-UTF-8 payload failed the scorer four times in a row.
These files exist to keep oversized tool output reachable - they are context
relief, not deliverables, and must not occupy delivery cards either.

Evidence: SELF - TestLatestDeliverablesKeepsLastPerName extended with a
tool-dump ref and asserts exclusion; go test ./internal/... passes; single-task
live rerun recorded on the phase02 task card.

Impact: latestDeliverables (gate input, verifier refs, reply deliverable list,
final artifact_ref cards) skips kind=data tool- prefixed refs. The spill files
remain artifacts for context backfill.
2026-10-09 14:56:34 +00:00
weichen 43297795f5 Keep original materials and cleared results reachable across turns
ci / vet-and-test (pull_request) Successful in 48s
Why: Real-run before/after evidence (phase01 card) showed later turns never
re-read the uploaded source (0 original reads in plan-followup) because the
attachment only existed in the current turn's injection and the context window
hard default was 32000 chars - a 25k-char transcript tripped the clear-on-first
request, replacing old tool results with a pointer the model has no tool to
follow. MiniMax is an interleaved-thinking model whose docs require <think>
blocks in assistant history; dropping them degrades later turns.

Evidence: SELF - new tests TestThinkReplayWrapsAssistantHistory,
TestContextWindowScalesFromTokens, TestThinkReplayEnabled,
TestPersistInputTextsAndListWorkspaceInputs; go test ./internal/... passes;
live before/after comparison on the golden task set recorded on the phase02
task card per the new policy clause.

Impact: models.chat.contextWindow/outputReserve/contextBuffer (tokens,
defaults 128k/16k/4k) drive the in-run clear threshold, converted to chars;
cleared tool results spill to <sandbox>/.agent/results with readable paths;
attachment parse text persists as inputs/<name>.md and the env section lists
all workspace inputs every turn; context.thinkReplay (auto|on|off, auto =
MiniMax) replays thinking blocks into request-side assistant history only.
2026-10-09 14:28:39 +00:00
weichen c0b52ac8bd Merge pull request 'Repair the quality-gate failure chain and add the live-model eval baseline' (#111) from feature/agent-quality-phase01 into main
ci / vet-and-test (push) Successful in 48s
2026-10-09 14:12:47 +00:00
weichen b68833f320 Read the eval target model from the user settings scope
ci / vet-and-test (pull_request) Successful in 47s
Why: The live report's model field came back empty because the settings
endpoint nests models under the user scope ({defaults, project, schema, user}),
not at the top level; the before-baseline report had to be annotated by hand.

Evidence: SELF - vet with -tags live passes; the scope shape verified against
GET /api/settings on the qa instance.

Impact: Future live reports carry the served model without manual annotation.
2026-10-09 13:04:08 +00:00
weichen d88dcb999f Wire the lint skills root from the runner instead of the app root
Why: lint-boundaries rejects new app->quality edges (architecture ratchet);
runtime already depends on quality for the scorer and holds SkillsDir, so the
injection belongs there. Same behavior, allowed dependency direction.

Evidence: SELF - go vet + go test ./internal/runtime ./internal/app pass;
lint-boundaries edge list unchanged.

Impact: quality.SetLintEnvRoot now called from LLMRunner.Execute when
SkillsDir is configured (idempotent, process-wide).
2026-10-09 12:55:54 +00:00
weichen 613fb27ba0 Add the live-model task eval harness (phase 0 baseline)
Why: No task-level measurement of real agent behavior existed, so the
failure chain documented in the diagnosis was invisible to CI. The harness
drives a configured backend over its public HTTP surface (upload, message,
poll, HITL auto-resolve), computes per-turn metrics (tool failures, full
regenerations, API-discovery calls, original-material reads, plan usage,
gate checks) and outcome checks (unverified facts vs material text, final
claims vs artifact headings), and writes a JSON + Markdown report.

Evidence: SELF - built behind the live build tag (CI skips); vet passes with
-tags live; drove the qa instance (remote main 1ebd46e) through the four
golden tasks end to end; report referenced on the phase01 task card.

Impact: New eval/live package (tasks.json + runner). Materials stay outside
the repo per eval/README; runs are opt-in via AGENT_EVAL_BASE.

Refs: Dev task feature/agent-quality-phase01; documents proposal
2026-10-09-agent-quality-improvement-plan.md §4 P0.
2026-10-09 12:49:44 +00:00
weichen 989379e924 Repair the quality-gate failure chain observed on real runs
Why: Real transcripts (dev instance, 62 assistant turns) showed the artifact
gate silently off and self-deadlocking. lintFile ran python3 -m housestyle.lint
without the skills PYTHONPATH, so the import failed and nil was treated as "no
issues" (same docx: 9 lint errors with PYTHONPATH, 0 without). stop() re-scored
every artifact registered in the run, including superseded versions, so a fixed
document could never pass and the composed reply reported the old verdict.
ctxasm rendered history artifacts as "[产出工件 …]" - the exact pattern
ClaimGate treats as a forged tool marker, so models copying the format were
rejected. scoreReadable flagged table cells like "项目概述撰写" as column
headings. Prompts and recipes said "python -m" (no python on PATH; exit 127)
and housestyle.summarize was invoked as a module that does not exist.

Evidence: SELF - new tests TestLintUnavailableFailsVisibleWhenSkillsRootSet,
TestLintCommandEnvCarriesSkillsRoot, TestDocxHeadingMarkedAndTableCellNotFlagged,
TestLatestDeliverablesKeepsLastPerName, TestRecipesUseRunnablePythonCommands,
TestLibraryUsesRunnablePythonCommands; go test ./internal/... passes; manual:
python3 -m housestyle.outline_cli reads a real artifact docx cleanly, lint
without PYTHONPATH exits 1 with ModuleNotFoundError and is now surfaced as
自检未运行 when the skills root is configured.

Impact: compose root injects quality.SetLintEnvRoot(<data>/skills). Final-turn
checks score only the newest artifact per name; deliverable lists and reply
cards follow the same dedupe. History artifact placeholders no longer collide
with the forged-marker regex. docx text extraction marks Title/HeadingN styles
so readability only judges real headings, with line numbers in the reason.
Check reminders no longer invite the model to ignore failed checks. Oversized
tool output is truncated by runes, not bytes.

Refs: Dev task feature/agent-quality-phase01 (quality gap diagnosis 2026-10-09).
2026-10-09 12:34:21 +00:00
185 changed files with 18569 additions and 453 deletions
+98 -1
View File
@@ -12,6 +12,7 @@ import (
"agent-system-backend/internal/app"
"agent-system-backend/internal/config"
"agent-system-backend/internal/store"
)
var version = "0.1.0-dev"
@@ -30,6 +31,8 @@ func main() {
os.Exit(runConfig(os.Args[2:]))
case "user":
os.Exit(runUser(os.Args[2:]))
case "identity":
os.Exit(runIdentity(os.Args[2:]))
}
}
fs := flag.NewFlagSet("serve", flag.ExitOnError)
@@ -45,6 +48,15 @@ func main() {
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)")
execWorkspace := fs.String("exec-workspace", "", "sandbox 档工作区模式:空=直写会话目录 | overlay=冻结基底+私有 upper+CAS 回收(S0)")
tier := fs.String("tier", "", "部署档:空=auto(DSN→PG,否则 SQLite)| local(SQLite,不允许 DSN)| team(PG fail-closed:缺 DSN/未认证拒绝启动,PG 故障拒绝就绪)")
pgMaxOpen := fs.Int("pg-max-open", 0, "PG 最大打开连接(0=默认 16)")
pgMaxIdle := fs.Int("pg-max-idle", 0, "PG 最大空闲连接(0=默认 8)")
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
fs.Parse(os.Args[1:])
cliSet := visited(fs)
@@ -52,16 +64,32 @@ func main() {
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
if msgs := resolved.TierProblems(); len(msgs) > 0 {
for _, msg := range msgs {
fmt.Fprintln(os.Stderr, "agent-system: tier guard:", msg)
}
os.Exit(1)
}
if err := app.Run(app.Options{
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
DisableExecTools: resolved.DisableExecTools,
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
ExecWorkspace: resolved.ExecWorkspace,
Tier: resolved.Tier,
PGPool: store.PoolOptions{
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
QueryTimeoutSec: resolved.PGQueryTimeoutSec,
},
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
@@ -80,6 +108,16 @@ func visited(fs *flag.FlagSet) map[string]bool {
name = "allowedHosts"
case "disable-exec-tools":
name = "disableExecTools"
case "pg-max-open":
name = "pgMaxOpen"
case "pg-max-idle":
name = "pgMaxIdle"
case "pg-conn-max-idle-sec":
name = "pgConnMaxIdleSec"
case "pg-conn-max-lifetime-sec":
name = "pgConnMaxLifetimeSec"
case "pg-query-timeout-sec":
name = "pgQueryTimeoutSec"
}
set[name] = true
})
@@ -105,11 +143,23 @@ func runConfig(args []string) int {
insecure := fs.Bool("insecure-listen", false, "")
allowedHosts := fs.String("allowed-hosts", "", "")
noExec := fs.Bool("disable-exec-tools", false, "")
execBackend := fs.String("exec-backend", "", "")
execDepsDir := fs.String("exec-deps-dir", "", "")
execWorkspace := fs.String("exec-workspace", "", "")
tier := fs.String("tier", "", "")
pgMaxOpen := fs.Int("pg-max-open", 0, "")
pgMaxIdle := fs.Int("pg-max-idle", 0, "")
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "")
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "")
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "")
fs.Parse(args[1:])
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, visited(fs))
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
@@ -235,3 +285,50 @@ func runUser(args []string) int {
return 2
}
}
// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。
// 与 serve --instance(runs 进程标识)无关。
func runIdentity(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system identity <ensure|verify> --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("identity "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录(SQLite 路径 <data>/agent-system.db)")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite)")
instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id,形如 svc-<group>-…)")
dbref := fs.String("dbref", "", "数据库锚点(空 = sqlite 或 pg)")
fs.Parse(args[1:])
if *instanceID == "" {
fmt.Fprintln(os.Stderr, "identity: --instance-id 必填")
return 2
}
ref := *dbref
if ref == "" {
if *dsn != "" {
ref = "pg"
} else {
ref = "sqlite"
}
}
switch sub {
case "ensure":
if err := app.EnsureSystemIdentity(*dsn, *data, *instanceID, ref); err != nil {
fmt.Fprintln(os.Stderr, "identity ensure:", err)
return 1
}
fmt.Printf("ensured instance_id=%s dbref=%s\n", *instanceID, ref)
return 0
case "verify":
if err := app.VerifySystemIdentity(*dsn, *data, *instanceID, ref); err != nil {
fmt.Fprintln(os.Stderr, "identity verify:", err)
return 1
}
fmt.Printf("verified instance_id=%s dbref=%s\n", *instanceID, ref)
return 0
default:
fmt.Fprintln(os.Stderr, "identity: 未知子命令", sub)
return 2
}
}
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
)
// U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例):
// 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 →
// 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。
func pgSchemaDSN(t *testing.T) (string, func()) {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真库集成由 CI 的必跑 job 覆盖")
}
schema := "mig_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("preinstall pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
}
cleanup := func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
}
return store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), cleanup
}
func TestMigrateEndToEndAndIdempotent(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
t.Fatalf("首次迁移:%v", err)
}
// 幂等重跑(同身份):表级 done 对账,零重复行。
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil {
t.Fatalf("幂等重跑:%v", err)
}
// 目标侧关键断言:布尔映射、NULL 保留、Unicode、BLOB、SERIAL 序列推进、
// 复合主键事件序。
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
db := st.DB()
var pinned, archived bool
if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived {
t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err)
}
var title, note string
if err := db.QueryRow(`SELECT title, session_note FROM conversations WHERE id='c1'`).Scan(&title, &note); err != nil || !strings.Contains(note, "注") {
t.Fatalf("Unicode:title=%q note=%q err=%v", title, note, err)
}
var vec []byte
if err := db.QueryRow(`SELECT vec FROM knowledge_vectors WHERE doc_id='k1'`).Scan(&vec); err != nil || len(vec) != 12 {
t.Fatalf("BLOB 向量:len=%d err=%v", len(vec), err)
}
var ended sql.NullString
if err := db.QueryRow(`SELECT ended_at FROM runs WHERE id='r1'`).Scan(&ended); err != nil || ended.Valid {
t.Fatalf("NULL 保留:ended=%v err=%v", ended, err)
}
var seqMax int
if err := db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id='c1'`).Scan(&seqMax); err != nil || seqMax != 2 {
t.Fatalf("事件复合主键序:max=%d err=%v", seqMax, err)
}
// 复制的显式 id 之后,应用侧插入不撞序列(command_audit.id=7 已复制)。
if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil {
t.Fatalf("序列推进失败(复制后插入撞主键):%v", err)
}
// 报告不含秘密值。
var report string
if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") {
t.Fatalf("报告泄密:err=%v", err)
}
}
func TestMigrateResumeAfterInterrupt(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
t.Fatal(err)
}
// 模拟断点:messages 已 done 后被部分回退(行删一半、状态回 pending),
// 重跑只重做该表,终态校验仍全绿。
db, err := sql.Open("pgx", target)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DELETE FROM messages WHERE id='m3'`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE migration_table_state SET state='pending' WHERE migration_id='mig-resume' AND table_name='messages'`); err != nil {
t.Fatal(err)
}
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil {
t.Fatalf("断点续跑:%v", err)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
t.Fatalf("续跑后 messages=%d:重做该表应恢复 3 行", n)
}
}
func TestMigrateRefusesForeignDataAndWrongSnapshot(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
// 非空目标(人工数据、无迁移身份):拒绝。
st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
st.Close()
db, err := sql.Open("pgx", target)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES ('foreign', '外来', '{}', '2026-10-11T00:00:00Z', '2026-10-11T00:00:00Z', 'alice')`); err != nil {
t.Fatal(err)
}
err = cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-x"})
if err == nil || !strings.Contains(err.Error(), "非空目标") {
t.Fatalf("非空目标应拒绝:%v", err)
}
if _, err := db.Exec(`DELETE FROM projects WHERE id='foreign'`); err != nil {
t.Fatal(err)
}
// 同 migration_id 换快照:拒绝(仅同一冻结快照可续传)。
if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-snap"}); err != nil {
t.Fatal(err)
}
other := buildFixture(t) // 不同文件 ⇒ 不同 snapshot_id
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-snap"})
if err == nil || !strings.Contains(err.Error(), "快照不同") {
t.Fatalf("换快照应拒绝:%v", err)
}
// 其他 migration_id 已存在:拒绝。
err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-other"})
if err == nil || !strings.Contains(err.Error(), "其他迁移记录") {
t.Fatalf("他身份应拒绝:%v", err)
}
}
func TestMigrateBlocksUnknownOwner(t *testing.T) {
target, cleanup := pgSchemaDSN(t)
defer cleanup()
fixture := buildFixture(t)
// 在夹具里造一个 users 之外的 owner 行(直接写源库)。
srcPath := fixture
st, err := store.OpenSQLite(srcPath) // 普通打开(夹具可写)
if err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
('k_ghost', 'p1', 'upload', '幽灵', 'g.md', '…', '2026-10-11T01:00:00Z', 'ghost-user')`); err != nil {
t.Fatal(err)
}
st.Close()
err = cmdMigrate([]string{"--source", srcPath, "--target", target, "--migration-id", "mig-ghost"})
if err == nil || !strings.Contains(err.Error(), "未知 owner") {
t.Fatalf("未知 owner 应阻塞:%v", err)
}
}
+855
View File
@@ -0,0 +1,855 @@
// storage-migrate:SQLite → PostgreSQL 受管迁移入口(协作存储方案 U4/R7)。
//
// 纪律(方案 §5 U4 / §6):
// - 只处理 Backend 库;不执行 SQLite dump 作为 PG SQL(按行复制 + 类型映射);
// - 迁移身份(migration_id + source_snapshot_id + source_schema_version)落
// 目标库 migration_control:仅同一冻结快照可续传;目标已含其他数据即拒绝;
// - 报告只含计数、校验和与归属检查结论——不含 DSN、密钥与行正文(KTD14)。
//
// 用法:
//
// storage-migrate inventory --source agent-system.db [--artifacts dir] [--out report.json]
// storage-migrate backup --source agent-system.db --out snapshot.db
// storage-migrate migrate --source snapshot.db --target "postgres://…" \
// --migration-id <名> [--artifacts dir] [--out report.json]
package main
import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "modernc.org/sqlite" // SQLite 驱动(源,只读打开)
"agent-system-backend/internal/store"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "inventory":
err = cmdInventory(os.Args[2:])
case "backup":
err = cmdBackup(os.Args[2:])
case "migrate":
err = cmdMigrate(os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "storage-migrate:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "用法:storage-migrate <inventory|backup|migrate> [flags](-h 看各子命令 flags)")
}
// ---- 公共小件 ----
func openSourceRO(path string) (*sql.DB, error) {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("source %s: %w", path, err)
}
// query_only:迁移源只读(冻结快照,任何写入都是缺陷)。
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=query_only(1)", path))
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("source %s: %w", path, err)
}
return db, nil
}
func fileSHA256(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
// ---- inventory:只读盘点(§6.1) ----
func cmdInventory(args []string) error {
fs := flag.NewFlagSet("inventory", flag.ExitOnError)
source := fs.String("source", "", "SQLite 库路径(活跃库或冻结快照)")
artifacts := fs.String("artifacts", "", "工件对象目录(可选;盘点文件清单与哈希)")
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
fs.Parse(args)
if *source == "" {
return fmt.Errorf("--source 必填")
}
db, err := openSourceRO(*source)
if err != nil {
return err
}
defer db.Close()
rep, err := inventoryReport(db, *source, *artifacts)
if err != nil {
return err
}
return writeReport(rep, *out)
}
func inventoryReport(db *sql.DB, source, artifactsDir string) (map[string]any, error) {
version, err := sourceSchemaVersion(db)
if err != nil {
return nil, err
}
var mode string
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
return nil, fmt.Errorf("journal_mode: %w", err)
}
var chk string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
return nil, fmt.Errorf("integrity_check: %v (%q)", err, chk)
}
tables, err := sqliteTableCounts(db)
if err != nil {
return nil, err
}
snap, size, err := fileSHA256(source)
if err != nil {
return nil, err
}
rep := map[string]any{
"command": "inventory", "source": source, "source_sha256": snap,
"source_bytes": size, "source_schema_version": version,
"journal_mode": mode, "integrity": chk, "table_counts": tables,
"generated_at": nowISO(),
}
if artifactsDir != "" {
manifest, err := artifactManifest(artifactsDir)
if err != nil {
return nil, err
}
rep["artifacts"] = manifest
}
return rep, nil
}
func sourceSchemaVersion(db *sql.DB) (int, error) {
var v int
err := db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&v)
if err != nil {
if strings.Contains(err.Error(), "no such table") {
return 0, fmt.Errorf("源库缺 schema_migrations(不是本工具管理的库):%w", err)
}
return 0, err
}
return v, nil
}
// sqliteTableCounts:仅盘点本工具认识的业务表(见 copyOrder)。
func sqliteTableCounts(db *sql.DB) (map[string]int64, error) {
out := map[string]int64{}
for _, t := range copyOrder {
var n int64
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
if strings.Contains(err.Error(), "no such table") {
continue // 旧库可能没有新表
}
return nil, fmt.Errorf("count %s: %w", t, err)
}
out[t] = n
}
return out, nil
}
func artifactManifest(dir string) (map[string]any, error) {
type entry struct {
Name, SHA string
Size int64
}
var entries []entry
hAll := sha256.New()
err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
sum, _, err := fileSHA256(p)
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, p)
entries = append(entries, entry{rel, sum, info.Size()})
fmt.Fprintf(hAll, "%s %d %s\n", rel, info.Size(), sum)
return nil
})
if err != nil {
return nil, fmt.Errorf("artifacts %s: %w", dir, err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
return map[string]any{
"dir": dir, "files": entries, "file_count": len(entries),
"manifest_sha256": hex.EncodeToString(hAll.Sum(nil)),
}, nil
}
// ---- backup:一致性快照(§6.2:VACUUM INTO + 可打开校验 + 计数对账) ----
func cmdBackup(args []string) error {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
source := fs.String("source", "", "活跃 SQLite 库路径")
out := fs.String("out", "", "快照输出路径(不得与源同文件;已存在则拒绝)")
fs.Parse(args)
if *source == "" || *out == "" {
return fmt.Errorf("--source 与 --out 必填")
}
if same, _ := filepath.EvalSymlinks(*source); same != "" {
if abs1, _ := filepath.Abs(same); abs1 != "" {
if abs2, _ := filepath.Abs(*out); abs2 == abs1 {
return fmt.Errorf("快照输出不得与源同文件")
}
}
}
if _, err := os.Stat(*out); err == nil {
return fmt.Errorf("快照输出已存在(不覆盖):%s", *out)
}
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
return err
}
// VACUUM INTO 在线安全(含 WAL 内容);query_only 会禁写,需普通打开。
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)", *source))
if err != nil {
return err
}
defer db.Close()
if _, err := db.Exec(`VACUUM INTO ?`, *out); err != nil {
return fmt.Errorf("vacuum into: %w", err)
}
// 校验:快照可打开、完整性、逐表计数与源一致。
snap, err := openSourceRO(*out)
if err != nil {
return err
}
defer snap.Close()
var chk string
if err := snap.QueryRow(`PRAGMA integrity_check`).Scan(&chk); err != nil || chk != "ok" {
return fmt.Errorf("快照完整性校验失败:%v (%q)", err, chk)
}
srcCounts, err := sqliteTableCounts(db)
if err != nil {
return err
}
snapCounts, err := sqliteTableCounts(snap)
if err != nil {
return err
}
for t, n := range srcCounts {
if snapCounts[t] != n {
return fmt.Errorf("快照计数不符:%s 源=%d 快照=%d", t, n, snapCounts[t])
}
}
sum, size, err := fileSHA256(*out)
if err != nil {
return err
}
fmt.Printf("backup ok: %s(%d 字节,sha256 %s…,%d 表计数一致)\n", *out, size, sum[:12], len(srcCounts))
return nil
}
// ---- migrate:受管迁移(§6.3 + U4) ----
// copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。
// admission_leases 是运行态执行许可(迁移无意义),明确排除。
var copyOrder = []string{
"projects", "users", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "events", "artifacts",
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "command_audit", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
"user_commands", "pending_notices", "recovery_holds",
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
}
const controlDDL = `
CREATE TABLE IF NOT EXISTS migration_control (
migration_id TEXT PRIMARY KEY,
source_snapshot_id TEXT NOT NULL,
source_schema_version INTEGER NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
verified_at TEXT,
report TEXT
);
CREATE TABLE IF NOT EXISTS migration_table_state (
migration_id TEXT NOT NULL,
table_name TEXT NOT NULL,
source_count INTEGER NOT NULL,
target_count INTEGER NOT NULL,
checksum TEXT NOT NULL,
state TEXT NOT NULL,
finished_at TEXT,
PRIMARY KEY (migration_id, table_name)
);`
func cmdMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
source := fs.String("source", "", "冻结快照路径(backup 子命令产物;不接受活跃库)")
target := fs.String("target", "", "目标 PostgreSQL DSN(须为空库或同身份续传库)")
migrationID := fs.String("migration-id", "", "迁移实例名(人工指定,报告与续传身份的一部分)")
artifacts := fs.String("artifacts", "", "工件对象目录(可选;仅盘点清单入报告,不复制)")
out := fs.String("out", "", "报告输出 JSON 路径(缺省打印到 stdout)")
fs.Parse(args)
if *source == "" || *target == "" || *migrationID == "" {
return fmt.Errorf("--source、--target、--migration-id 必填")
}
src, err := openSourceRO(*source)
if err != nil {
return err
}
defer src.Close()
snapID, _, err := fileSHA256(*source)
if err != nil {
return err
}
srcVersion, err := sourceSchemaVersion(src)
if err != nil {
return err
}
// 目标:先用 store 同款迁移器建 schema/扩展(OpenPostgres 会跑迁移与
// 未知版本门禁),再落控制表。DSN 不回显、不入报告。
st, err := store.OpenPostgres(*target, filepath.Join(filepath.Dir(*source), "migrate-artifacts"))
if err != nil {
return fmt.Errorf("open target: %w", err)
}
defer st.Close()
db := st.DB()
if _, err := db.Exec(controlDDL); err != nil {
return fmt.Errorf("control tables: %w", err)
}
if err := guardIdentity(db, *migrationID, snapID, srcVersion); err != nil {
return err
}
if err := ensureControlRow(db, *migrationID, snapID, srcVersion); err != nil {
return err
}
rep := map[string]any{
"command": "migrate", "migration_id": *migrationID,
"source_snapshot_id": snapID, "source_schema_version": srcVersion,
"started_at": nowISO(),
}
var tables []map[string]any
for _, t := range copyOrder {
if !sqliteHasTable(src, t) {
continue // 旧库无此表
}
entry, err := migrateTable(db, src, *migrationID, t)
if err != nil {
return fmt.Errorf("table %s: %w", t, err)
}
tables = append(tables, entry)
}
// 未知 owner 检查(U4:阻塞,不静默)。'' 与 'local' 是单租户默认身份。
if err := checkOwners(db, src); err != nil {
return err
}
// 校验:目标逐表重算计数与校验和,全部一致才落 verified。
if err := verifyTables(db, *migrationID, tables); err != nil {
return err
}
var totalSrc, totalDst int64
for _, e := range tables {
totalSrc += e["source_count"].(int64)
totalDst += e["target_count"].(int64)
}
rep["tables"] = tables
rep["rows_total"] = totalSrc
rep["verified_at"] = nowISO()
if *artifacts != "" {
manifest, err := artifactManifest(*artifacts)
if err != nil {
return err
}
rep["artifacts"] = manifest
}
repJSON, _ := json.Marshal(rep)
if _, err := db.Exec(`UPDATE migration_control SET finished_at=$1, verified_at=$2, report=$3 WHERE migration_id=$4`,
nowISO(), rep["verified_at"], string(repJSON), *migrationID); err != nil {
return err
}
return writeReport(rep, *out)
}
// guardIdentity:目标非空且身份不同即拒绝(R7:不覆盖他人数据)。
// - 存在其他 migration_id 的完成记录 → 拒绝;
// - 同 migration_id 但 source_snapshot_id 不同(换了快照续传)→ 拒绝;
// - 业务表已有数据而控制表为空(人工造过数据)→ 拒绝。
func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM migration_control WHERE migration_id<>$1`, migrationID).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf("目标库已有其他迁移记录(%d 条):拒绝覆盖,换空库或专项回迁方案", n)
}
var snap, started string
var sv int
err := db.QueryRow(`SELECT source_snapshot_id, source_schema_version, started_at FROM migration_control WHERE migration_id=$1`, migrationID).Scan(&snap, &sv, &started)
switch {
case err == sql.ErrNoRows:
// 新迁移:业务表必须为空。
busy, err := businessRows(db)
if err != nil {
return err
}
if busy > 0 {
return fmt.Errorf("目标库业务表已有 %d 行且无迁移身份:拒绝(非空目标不得作为迁移目标)", busy)
}
case err != nil:
return err
default:
if snap != snapID {
return fmt.Errorf("同 migration_id 但快照不同(库=%s… 本次=%s…):仅同一冻结快照可续传", snap[:12], snapID[:12])
}
_ = sv
_ = started
}
return nil
}
func businessRows(db *sql.DB) (int64, error) {
var total int64
for _, t := range copyOrder {
var n int64
if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil {
if strings.Contains(err.Error(), "does not exist") {
continue
}
return 0, err
}
total += n
}
return total, nil
}
func ensureControlRow(db *sql.DB, migrationID, snapID string, srcVersion int) error {
_, err := db.Exec(`INSERT INTO migration_control (migration_id, source_snapshot_id, source_schema_version, started_at)
VALUES ($1,$2,$3,$4)
ON CONFLICT(migration_id) DO NOTHING`, migrationID, snapID, srcVersion, nowISO())
return err
}
func sqliteHasTable(db *sql.DB, name string) bool {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n); err != nil {
return false
}
return n > 0
}
// migrateTable:done 则对账(幂等重跑);pending 则按行复制(布尔映射 +
// 批量 INSERT + 序列推进)后落 done 与源侧校验和。
func migrateTable(db *sql.DB, src *sql.DB, migrationID, table string) (map[string]any, error) {
cols, pk, err := targetColumns(db, table)
if err != nil {
return nil, err
}
var state string
var srcCount, dstCountStored int64
var srcChecksum string
err = db.QueryRow(`SELECT state, source_count, target_count, checksum FROM migration_table_state WHERE migration_id=$1 AND table_name=$2`,
migrationID, table).Scan(&state, &srcCount, &dstCountStored, &srcChecksum)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if err == sql.ErrNoRows {
state = "pending"
}
// 源侧计数与校验和始终重算(冻结快照上必然稳定;差异=快照被换,拒绝)。
bools, err := boolColumns(db, table, cols)
if err != nil {
return nil, err
}
countNow, sumNow, err := sourceScan(src, table, cols, pk, bools)
if err != nil {
return nil, err
}
if state == "done" {
if countNow != srcCount || sumNow != srcChecksum {
return nil, fmt.Errorf("源与登记不符(疑似换快照):count %d/%d checksum %s…/%s…", countNow, srcCount, sumNow[:12], srcChecksum[:12])
}
entry := map[string]any{"table": table, "source_count": countNow, "target_count": dstCountStored, "checksum": sumNow, "state": "done(idempotent)"}
return entry, nil
}
if err := copyRows(db, src, table, cols); err != nil {
return nil, err
}
dstCount, _, err := targetScan(db, table, cols, pk, bools)
if err != nil {
return nil, err
}
if dstCount != countNow {
return nil, fmt.Errorf("复制后计数不符:源=%d 目标=%d", countNow, dstCount)
}
if err := advanceSequences(db, table); err != nil {
return nil, err
}
if _, err := db.Exec(`INSERT INTO migration_table_state (migration_id, table_name, source_count, target_count, checksum, state, finished_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(migration_id, table_name) DO UPDATE SET source_count=excluded.source_count, target_count=excluded.target_count, checksum=excluded.checksum, state=excluded.state, finished_at=excluded.finished_at`,
migrationID, table, countNow, dstCount, sumNow, "done", nowISO()); err != nil {
return nil, err
}
return map[string]any{"table": table, "source_count": countNow, "target_count": dstCount, "checksum": sumNow, "state": "done"}, nil
}
// targetColumns:目标侧列清单(按名排序)+ 主键列。布尔列集合同取自目标
// information_schema(类型映射以 PG schema 为准——SQLite 侧 0/1 → bool)。
func targetColumns(db *sql.DB, table string) (cols []string, pk []string, err error) {
// 生成列(PG GENERATED ALWAYS,如 knowledge.tsv)与库内向量副本列
// (vec_pg:BLOB 权威的派生表示,U6-3)不复制不校验:PG 从业务列
// 派生或由 Upsert 双写,源库(SQLite)没有该列。
rows, err := db.Query(`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1 AND is_generated='NEVER'
AND column_name NOT IN ('vec_pg') ORDER BY column_name`, table)
if err != nil {
return nil, nil, err
}
defer rows.Close()
exists := false
for rows.Next() {
var name, dtype string
if err := rows.Scan(&name, &dtype); err != nil {
return nil, nil, err
}
exists = true
cols = append(cols, name)
}
if !exists {
return nil, nil, fmt.Errorf("目标缺表 %s(迁移器未建?)", table)
}
prows, err := db.Query(`
SELECT a.attname FROM pg_index i
JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey)
WHERE i.indisprimary AND i.indrelid=$1::regclass`, table)
if err != nil {
return nil, nil, err
}
defer prows.Close()
for prows.Next() {
var p string
if err := prows.Scan(&p); err != nil {
return nil, nil, err
}
pk = append(pk, p)
}
if len(pk) == 0 {
pk = cols // 无主键表按全列序校验
}
return cols, pk, nil
}
func boolColumns(db *sql.DB, table string, cols []string) (map[string]bool, error) {
out := map[string]bool{}
rows, err := db.Query(`
SELECT column_name FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1 AND data_type='boolean'`, table)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out[c] = true
}
return out, nil
}
// canonicalRow:稳定行编码(校验和用):列名排序、值规范化
// (bool→true/false、[]byte→base64、NULL→null、数值/字符串原样)。
func canonicalRow(cols []string, vals []any, bools map[string]bool) string {
type kv struct {
K string
V any
}
pairs := make([]kv, len(cols))
for i := range cols {
pairs[i] = kv{cols[i], normalize(vals[i], bools[cols[i]])}
}
b, _ := json.Marshal(pairs)
return string(b)
}
// normalize:布尔列按目标 schema 对称归一(源 int64 0/1、目标 bool 编码一致,
// 否则校验和必然不符——真库实测);[]byte→base64。
func normalize(v any, isBool bool) any {
if isBool {
return sqliteBool(v)
}
switch t := v.(type) {
case []byte:
return base64.StdEncoding.EncodeToString(t)
default:
return v
}
}
// hashChain:按主键序哈希链(前后衔接,顺序敏感)。
func hashChain(prev string, rowJSON string) string {
h := sha256.Sum256([]byte(prev + "|" + rowJSON))
return hex.EncodeToString(h[:])
}
// scanChecksum:源/目标共用的扫描对账(列清单一致、主键序、行哈希链)。
func scanChecksum(rows *sql.Rows, cols []string, bools map[string]bool) (int64, string, error) {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
var count int64
sum := ""
for rows.Next() {
if err := rows.Scan(ptrs...); err != nil {
return 0, "", err
}
sum = hashChain(sum, canonicalRow(cols, vals, bools))
count++
}
return count, sum, rows.Err()
}
func sourceScan(src *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
orderBy := "ORDER BY " + quoteJoin(pk, `"`)
rows, err := src.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ` + orderBy)
if err != nil {
return 0, "", err
}
defer rows.Close()
return scanChecksum(rows, cols, bools)
}
func targetScan(db *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) {
rows, err := db.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ORDER BY ` + quoteJoin(pk, `"`))
if err != nil {
return 0, "", err
}
defer rows.Close()
return scanChecksum(rows, cols, bools)
}
func quoteJoin(names []string, q string) string {
out := make([]string, len(names))
for i, n := range names {
out[i] = q + n + q
}
return strings.Join(out, ",")
}
// copyRows:按行复制。布尔列 0/1→false/true;其余原样(TEXT/INT/REAL/BLOB)。
func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error {
bools, err := boolColumns(db, table, cols)
if err != nil {
return err
}
colList := quoteJoin(cols, `"`)
ph := make([]string, len(cols))
for i := range ph {
ph[i] = fmt.Sprintf("$%d", i+1)
}
placeholders := strings.Join(ph, ",")
srcRows, err := src.Query(`SELECT ` + colList + ` FROM "` + table + `"`)
if err != nil {
return err
}
defer srcRows.Close()
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders))
if err != nil {
return err
}
defer stmt.Close()
var batch int
for srcRows.Next() {
if err := srcRows.Scan(ptrs...); err != nil {
return err
}
args := make([]any, len(cols))
for i, c := range cols {
v := vals[i]
if bools[c] {
args[i] = sqliteBool(v)
} else {
args[i] = v
}
}
if _, err := stmt.Exec(args...); err != nil {
return fmt.Errorf("insert: %w", err)
}
batch++
}
if err := srcRows.Err(); err != nil {
return err
}
return tx.Commit()
}
func sqliteBool(v any) bool {
switch t := v.(type) {
case int64:
return t != 0
case bool:
return t
default:
return false
}
}
// advanceSequences:SERIAL/identity 列复制后推进序列(显式插入不推进,
// 后续应用插入会撞主键)。无序列表为空操作。
func advanceSequences(db *sql.DB, table string) error {
rows, err := db.Query(`
SELECT pg_get_serial_sequence($1, column_name)
FROM information_schema.columns
WHERE table_schema=current_schema() AND table_name=$1
AND (column_default LIKE 'nextval%' OR is_identity='YES')`, table)
if err != nil {
return err
}
defer rows.Close()
var seqs []string
for rows.Next() {
var s any
if err := rows.Scan(&s); err != nil {
return err
}
if s != nil {
seqs = append(seqs, s.(string))
}
}
if err := rows.Err(); err != nil {
return err
}
for _, s := range seqs {
if _, err := db.Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM "%s"), 1))`, s, table)); err != nil {
return fmt.Errorf("setval %s: %w", s, err)
}
}
return nil
}
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
func checkOwners(db *sql.DB, src *sql.DB) error {
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"}
for _, t := range ownerTables {
if !sqliteHasTable(src, t) {
continue
}
rows, err := src.Query(`SELECT DISTINCT owner FROM "` + t + `" WHERE owner<>'' AND owner<>'local'`)
if err != nil {
return err
}
var unknown []string
for rows.Next() {
var o string
if err := rows.Scan(&o); err != nil {
rows.Close()
return err
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE owner=$1`, o).Scan(&n); err != nil {
rows.Close()
return err
}
if n == 0 {
unknown = append(unknown, o)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(unknown) > 0 {
return fmt.Errorf("表 %s 引用未知 owner(users 无此行):%v——先在目标补建用户或修正归属,再迁移", t, unknown)
}
}
return nil
}
// verifyTables:目标逐表重算,与登记的源计数/校验和全部一致才通过。
func verifyTables(db *sql.DB, migrationID string, tables []map[string]any) error {
for _, e := range tables {
table := e["table"].(string)
wantCount := e["source_count"].(int64)
wantSum := e["checksum"].(string)
cols, pk, err := targetColumns(db, table)
if err != nil {
return err
}
bools, err := boolColumns(db, table, cols)
if err != nil {
return err
}
gotCount, gotSum, err := targetScan(db, table, cols, pk, bools)
if err != nil {
return err
}
if gotCount != wantCount || gotSum != wantSum {
return fmt.Errorf("表 %s 校验不符:count %d/%d checksum %s…/%s…", table, gotCount, wantCount, gotSum[:12], wantSum[:12])
}
}
return nil
}
func writeReport(rep map[string]any, out string) error {
b, err := json.MarshalIndent(rep, "", " ")
if err != nil {
return err
}
if out == "" {
fmt.Println(string(b))
return nil
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
return os.WriteFile(out, append(b, '\n'), 0o644)
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
)
// U4 迁移工具测试:夹具覆盖全部当前 schema 的边值(布尔/空值/时间/JSON/
// Unicode/二进制向量/秘密字段),以及 inventory/backup 的行为面。
// 真库端到端(幂等/续传/非空拒/未知 owner)在 integration_test.go(PG 门控)。
// buildFixture:建一个全 schema 的 SQLite 库并填充边值行,返回路径。
func buildFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "fixture.db")
st, err := store.OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
seedEdgeRows(t, st)
return path
}
func seedEdgeRows(t *testing.T, st *store.Store) {
t.Helper()
// users(含 is_admin/must_change 布尔)与单租户默认身份。
mustExec(t, st, `INSERT INTO users (id, owner, name, created_at, password_hash, password_set_at, must_change_password, is_admin) VALUES
('u1', 'alice', '爱丽丝', '2026-10-11T01:00:00Z', 'sha256:…', '2026-10-11T01:00:00Z', false, true),
('u2', 'bob', 'Bob 😊', '2026-10-11T01:00:00Z', '', '', true, false)`)
// 项目与会话(布尔 pinned/archived/allow_cross_read/cross_private、空 JSON)。
mustExec(t, st, `INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES
('p1', '项目一', '{}', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
('p2', 'Emoji 项目 🚀', '{"a":null,"b":[1,2,"三"]}', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
mustExec(t, st, `INSERT INTO conversations (id, project_id, title, profile_id, created_at, updated_at, owner, pinned, archived, allow_cross_read, cross_private, permission_mode, session_note) VALUES
('c1', 'p1', '会话', '', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice', true, false, false, true, '', 'note—注'),
('c2', 'p2', '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '', false, true, true, false, 'strict', '')`)
// 消息(request_id 幂等、parts JSON 含空对象/空数组/Unicode)与 seq 顺序。
mustExec(t, st, `INSERT INTO messages (id, conversation_id, run_id, role, parts, seq, created_at, request_id) VALUES
('m1', 'c1', '', 'user', '[{"type":"text","text":"你好"}]', 1, '2026-10-11T01:00:01Z', 'req-1'),
('m2', 'c1', '', 'assistant', '[{"type":"kv","kv":[]},{"type":"text","text":""}]', 2, '2026-10-11T01:00:02Z', ''),
('m3', 'c2', '', 'system', '[]', 1, '2026-10-11T02:00:01Z', 'req-2')`)
// runs(NULL ended_at/usage/error)。
mustExec(t, st, `INSERT INTO runs (id, parent_run_id, conversation_id, project_id, owner, kind, profile_id, status, phase, agent_name, created_at, instance_id) VALUES
('r1', '', 'c1', 'p1', 'alice', 'conversation', '', 'completed', '', '', '2026-10-11T01:00:00Z', 'host:1'),
('r2', 'r1', 'c1', 'p1', 'alice', 'subagent', '', 'running', 'thinking', '检索', '2026-10-11T01:00:03Z', 'host:2')`)
// events(复合主键 (conversation_id, seq))。
mustExec(t, st, `INSERT INTO events (conversation_id, seq, run_id, ts, v, type, payload) VALUES
('c1', 1, 'r1', '2026-10-11T01:00:01Z', 1, 'run.phase', '{"phase":"thinking"}'),
('c1', 2, 'r1', '2026-10-11T01:00:02Z', 1, 'message.appended', NULL),
('c2', 1, '', '2026-10-11T02:00:01Z', 1, 'x', '{}')`)
// 工件(size 0 与大值)。
mustExec(t, st, `INSERT INTO artifacts (id, project_id, name, kind, mime, size, source_run, content_path, created_at, updated_at, owner) VALUES
('a1', 'p1', '报告.md', 'doc', 'text/markdown', 0, 'r1', 'art/a1', '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 'alice'),
('a2', 'p2', '数据.xlsx', 'doc', '', 2147483647, '', '', '2026-10-11T02:00:00Z', '2026-10-11T02:00:00Z', '')`)
// HITL 卡(resolved=0、resolution NULL)。
mustExec(t, st, `INSERT INTO hitl_cards (id, run_id, conversation_id, payload, created_at, resolved, resolution, owner) VALUES
('h1', 'r2', 'c1', '{"q":"继续?"}', '2026-10-11T01:00:04Z', 0, NULL, 'alice')`)
// 知识 + 向量(BLOB 二进制:float32 little-endian)。
mustExec(t, st, `INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES
('k1', 'p1', 'upload', '标题', 'doc.md', '正文内容…', '2026-10-11T01:00:00Z', 'alice')`)
mustExec(t, st, `INSERT INTO knowledge_vectors (doc_id, owner, ref, dim, vec, created_at) VALUES
('k1', 'alice', 'doc.md', 3, x'0000803F0000003F000080BF', '2026-10-11T01:00:00Z')`)
// 活动卡(pinned 布尔)。
mustExec(t, st, `INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version) VALUES
('ac1', 'p1', 'c1', '', 'alice', '{"title":"卡"}', 'active', 1, '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 2)`)
// 命令审计(SERIAL 主键:显式 id 插入)。
mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES
(7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'done', 'op-1', 'c1', '{"actor":"alice"}', 'alice')`)
// 设置('' 默认租户 scope)。
mustExec(t, st, `INSERT INTO settings (owner, scope, "values") VALUES ('', 'runtime', '{"rateLimitPerMin":0}')`)
// 秘密字段(值不外泄:迁移按行复制,报告无正文)。
mustExec(t, st, `INSERT INTO secret_keys (id, owner, service, label, value, active, created_at) VALUES
('sk1', 'alice', 'openai', 'main', 'sk-secret-value-不打印', 1, '2026-10-11T01:00:00Z')`)
// 恢复隔离(业务态,应随迁)。
mustExec(t, st, `INSERT INTO recovery_holds (run_id, owner, conversation_id, reason, created_at) VALUES
('rh1', 'alice', 'c1', '副作用未知', '2026-10-11T01:00:00Z')`)
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// mustExec:夹具直接走底层连接写行(业务代码不得如此;见 store.DB 注释)。
func mustExec(t *testing.T, st *store.Store, q string) {
t.Helper()
if _, err := st.DB().Exec(q); err != nil {
t.Fatal(err)
}
}
// ---- inventory ----
func TestInventoryReport(t *testing.T) {
path := buildFixture(t)
db, err := openSourceRO(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
rep, err := inventoryReport(db, path, "")
if err != nil {
t.Fatal(err)
}
if rep["source_schema_version"].(int) < 20 {
t.Fatalf("schema version: %v", rep["source_schema_version"])
}
counts := rep["table_counts"].(map[string]int64)
if counts["messages"] != 3 || counts["events"] != 3 || counts["users"] != 2 {
t.Fatalf("counts: %v", counts)
}
b, _ := json.Marshal(rep)
if strings.Contains(string(b), "sk-secret-value") {
t.Fatal("inventory 报告不得包含秘密值")
}
}
func TestInventoryMissingFile(t *testing.T) {
if _, err := openSourceRO(filepath.Join(t.TempDir(), "nope.db")); err == nil {
t.Fatal("缺文件应报错")
}
}
// ---- backup ----
func TestBackupVerifiesAndSnapshotsConsistent(t *testing.T) {
path := buildFixture(t)
out := filepath.Join(t.TempDir(), "snap.db")
if err := cmdBackup([]string{"--source", path, "--out", out}); err != nil {
t.Fatal(err)
}
snap, err := openSourceRO(out)
if err != nil {
t.Fatal(err)
}
defer snap.Close()
var n int
if err := snap.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 {
t.Fatalf("snap messages: %d %v", n, err)
}
// 已存在的输出拒绝覆盖。
if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil {
t.Fatal("已存在快照应拒绝")
}
}
// ---- 夹具即文档:确认全部业务表在 copyOrder 内(防新表漏迁) ----
func TestCopyOrderCoversAllFixtureTables(t *testing.T) {
path := buildFixture(t)
db, err := openSourceRO(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
known := map[string]bool{}
for _, t := range copyOrder {
known[t] = true
}
known["schema_migrations"] = true // 迁移器管理,不随迁
// 运行态表不随迁:执行许可是进程瞬态;执行租约与跨实例命令属当前
// 实例的调度现场(接管语义在 U5 内由 lease/fencing 处理,不复制历史)。
for _, rt := range []string{"admission_leases", "conversation_executions", "run_commands"} {
known[rt] = true
}
// FTS5 虚表影子表:SQLite 内部结构,PG 侧由 v4 生成列/索引从 knowledge
// 行自动重建,不随迁。
for _, shadow := range []string{"knowledge_fts", "knowledge_fts_data", "knowledge_fts_idx", "knowledge_fts_docsize", "knowledge_fts_config"} {
known[shadow] = true
}
var missing []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
if !known[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("copyOrder 漏表(新表加入 schema 后须同步工具清单):%v", missing)
}
}
+457
View File
@@ -0,0 +1,457 @@
//go:build live
package live
import (
"archive/zip"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"agent-system-backend/internal/runtime"
)
// upload 上传一个材料文件,返回 artifact id。
func (c *client) upload(t *testing.T, projectID, name string, content []byte) string {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", name)
if err != nil {
t.Fatalf("multipart field: %v", err)
}
if _, err := fw.Write(content); err != nil {
t.Fatalf("multipart write: %v", err)
}
w.Close()
req, err := http.NewRequest("POST", c.base+"/api/projects/"+projectID+"/uploads", &buf)
if err != nil {
t.Fatalf("upload request: %v", err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.http.Do(req)
if err != nil {
t.Fatalf("upload: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
t.Fatalf("upload %s: %s: %s", name, resp.Status, truncate(string(raw), 300))
}
var out struct {
ID string `json:"artifactId"`
LegacyID string `json:"id"`
}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("upload response: %v %s", err, truncate(string(raw), 300))
}
if out.ID == "" {
out.ID = out.LegacyID
}
if out.ID == "" {
t.Fatalf("upload response missing artifact id: %s", truncate(string(raw), 300))
}
return out.ID
}
// runTurn 发一轮消息并等到 run 终态;期间自动应答 HITL 卡。
func runTurn(t *testing.T, c *client, projectID, convID, text string, attachmentIDs []string, turnTimeout time.Duration) turnResult {
t.Helper()
started := time.Now()
var out struct {
Message message `json:"message"`
Run run `json:"run"`
}
body := map[string]any{"text": text, "requestId": fmt.Sprintf("eval-%d", time.Now().UnixNano())}
if len(attachmentIDs) > 0 {
body["attachmentIds"] = attachmentIDs
}
if err := c.do("POST", "/api/conversations/"+convID+"/messages", body, &out); err != nil {
t.Fatalf("post message: %v", err)
}
runID := out.Run.ID
deadline := time.Now().Add(turnTimeout)
var cur run
for {
var resp struct {
Run run `json:"run"`
}
if err := c.do("GET", "/api/runs/"+runID, nil, &resp); err != nil {
t.Fatalf("get run: %v", err)
}
cur = resp.Run
if isTerminal(cur.Status) {
break
}
autoResolveHITL(t, c, convID)
if time.Now().After(deadline) {
t.Fatalf("run %s timed out after %s (status=%s)", runID, turnTimeout, cur.Status)
}
time.Sleep(3 * time.Second)
}
detail := struct {
Messages []message `json:"messages"`
ActiveHitl []hitlCard `json:"activeHitl"`
}{}
if err := c.do("GET", "/api/conversations/"+convID, nil, &detail); err != nil {
t.Fatalf("conversation detail: %v", err)
}
var assistant *message
for i := range detail.Messages {
m := detail.Messages[i]
if m.RunID == runID && m.Role == "assistant" && !m.Excluded {
assistant = &detail.Messages[i]
}
}
tr := turnResult{User: text, RunID: runID, Status: cur.Status, DurationMs: time.Since(started).Milliseconds()}
if cur.Usage != nil {
tr.Usage = &cur
}
if assistant == nil {
tr.Issues = append(tr.Issues, "没有找到本回合的助手消息")
return tr
}
collectMetrics(&tr, assistant)
// 工件清单:本 run 产出的。
var arts []artifactMeta
if err := c.do("GET", "/api/projects/"+projectID+"/artifacts", nil, &arts); err == nil {
for _, a := range arts {
if a.SourceRun == runID {
tr.Artifacts = append(tr.Artifacts, a.Name)
}
}
}
if cur.Status != "completed" {
tr.Issues = append(tr.Issues, "run 终态="+cur.Status)
}
return tr
}
func isTerminal(status string) bool {
switch status {
case "completed", "failed", "cancelled":
return true
}
return false
}
// autoResolveHITL 无人值守:有活动确认卡时选第一个选项(没有选项给自由文本同意)。
func autoResolveHITL(t *testing.T, c *client, convID string) {
t.Helper()
detail := struct {
ActiveHitl []hitlCard `json:"activeHitl"`
}{}
if err := c.do("GET", "/api/conversations/"+convID, nil, &detail); err != nil {
return
}
for _, card := range detail.ActiveHitl {
resp := map[string]string{}
if len(card.Choices) > 0 {
resp["choiceId"] = card.Choices[0].ID
} else if card.AllowFreeInput {
resp["freeText"] = "同意继续(评测自动应答)"
} else {
continue
}
_ = c.do("POST", "/api/hitl/"+card.ID+"/resolve", resp, nil)
}
}
func collectMetrics(tr *turnResult, m *message) {
for _, p := range m.Parts {
switch p.Type {
case "text":
if !p.Process && strings.TrimSpace(p.Text) != "" {
tr.FinalText = p.Text
}
case "tool_call":
if p.ToolCall == nil {
continue
}
tr.Metrics.ToolCalls++
args := p.ToolCall.Arguments
switch p.ToolCall.Name {
case "update_plan", "update_todos":
tr.Metrics.PlanUpdates++
case "run_python":
if strings.Contains(args, "new_document(") || strings.Contains(args, "new_presentation(") || strings.Contains(args, "new_workbook(") {
tr.Metrics.FullRegens++
}
}
if strings.Contains(args, "dir(") || strings.Contains(args, "inspect.signature") || strings.Contains(args, "help(") {
tr.Metrics.APIDiscovery++
}
if strings.Contains(args, "inputs/") {
tr.Metrics.ReadsOriginal++
}
case "tool_result":
if p.ToolResult == nil {
continue
}
s := p.ToolResult.Summary
if p.ToolResult.Status == "error" || strings.HasPrefix(s, "exit=1") || strings.Contains(s, "Traceback") || strings.Contains(s, "ModuleNotFoundError") {
tr.Metrics.ToolFailures++
}
case "check":
if p.Check == nil {
continue
}
tr.Metrics.VerifierChecks++
tr.Checks = append(tr.Checks, checkRecord{Passed: p.Check.Passed, Reason: p.Check.Reason})
}
}
}
func lastArtifactTurn(tr taskResult) *turnResult {
for i := len(tr.Turns) - 1; i >= 0; i-- {
if len(tr.Turns[i].Artifacts) > 0 {
return &tr.Turns[i]
}
}
return nil
}
func downloadArtifactText(t *testing.T, c *client, projectID, name string) string {
t.Helper()
var arts []artifactMeta
if err := c.do("GET", "/api/projects/"+projectID+"/artifacts", nil, &arts); err != nil {
return ""
}
for _, a := range arts {
if a.Name != name {
continue
}
req, _ := http.NewRequest("GET", c.base+"/api/artifacts/"+a.ID+"/content", nil)
resp, err := c.http.Do(req)
if err != nil {
continue
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if strings.HasSuffix(strings.ToLower(a.Name), ".docx") {
return docxTextIfDocx(t, a.Name, raw)
}
return string(raw)
}
return ""
}
// ---- 文本抽取与事实核查 ----
// docxTextIfDocx:docx 抽纯文本(标题行带 # 前缀);其他类型原样返回。
func docxTextIfDocx(t *testing.T, name string, raw []byte) string {
t.Helper()
if !strings.HasSuffix(strings.ToLower(name), ".docx") {
return string(raw)
}
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return ""
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return ""
}
defer rc.Close()
dec := xml.NewDecoder(rc)
var b strings.Builder
var para strings.Builder
style, inPara := "", false
flush := func() {
text := strings.TrimSpace(para.String())
if text != "" {
if lvl := docxHeadingLevel(style); lvl > 0 {
b.WriteString(strings.Repeat("#", lvl) + " ")
}
b.WriteString(text + "\n")
}
para.Reset()
style = ""
}
for {
tok, err := dec.Token()
if err != nil {
break
}
switch el := tok.(type) {
case xml.StartElement:
switch el.Name.Local {
case "p":
inPara, style = true, ""
para.Reset()
case "pStyle":
if inPara {
for _, at := range el.Attr {
if at.Name.Local == "val" {
style = at.Value
}
}
}
}
case xml.EndElement:
if el.Name.Local == "p" {
inPara = false
flush()
}
case xml.CharData:
if inPara {
para.Write(bytes.TrimSpace(el))
}
}
}
flush()
return b.String()
}
return ""
}
func docxHeadingLevel(style string) int {
switch {
case style == "Title":
return 1
case style == "Subtitle":
return 2
case strings.HasPrefix(style, "Heading"):
n := 0
for _, ch := range style[len("Heading"):] {
if ch < '0' || ch > '9' {
return 0
}
n = n*10 + int(ch-'0')
}
if n >= 1 && n <= 9 {
return n
}
}
return 0
}
var (
pctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
dateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日`)
bigNumRe = regexp.MustCompile(`\d{4,}`)
claimRe = regexp.MustCompile(`(?:^|\n|\s)([一二三四五六七八九十]{1,2}、[^\s,。;|]{2,24})`)
)
// unverifiedFacts 抽取交付物里的百分比/日期/大数字,逐个回材料原文找出处。
// 找不到的列为未核实事实(模型编造或来自模型自带的「常识」)。
func unverifiedFacts(artifactText, materialText string) []string {
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
return nil
}
seen := map[string]bool{}
var out []string
check := func(tok string) {
tok = strings.TrimSpace(tok)
if tok == "" || seen[tok] {
return
}
seen[tok] = true
if !strings.Contains(materialText, tok) {
out = append(out, tok)
}
}
for _, m := range pctRe.FindAllString(artifactText, -1) {
check(m)
}
for _, m := range dateRe.FindAllString(artifactText, -1) {
check(m)
}
for _, m := range bigNumRe.FindAllString(artifactText, -1) {
if len(m) >= 5 { // 4 位数大量是年份/编号,只报 5 位以上
check(m)
}
}
return out
}
// unmarkedFacts 从未核实清单里剔除已按「目标/预计/假设」标注的(与后端
// runtime.MarkedAsEstimate 同一口径,标注词邻域 ±32 字符)。
func unmarkedFacts(tokens []string, artifactText string) []string {
var out []string
for _, tok := range tokens {
if !runtime.MarkedAsEstimate(artifactText, tok) {
out = append(out, tok)
}
}
return out
}
// finalVsArtifact 终轮回复里声称的章节(「一、…」)必须能在工件里找到。
func finalVsArtifact(turn *turnResult, artifactText string) []string {
if turn == nil || turn.FinalText == "" || strings.TrimSpace(artifactText) == "" {
return nil
}
final := turn.FinalText
var out []string
seen := map[string]bool{}
for _, m := range claimRe.FindAllStringSubmatch(final, -1) {
claim := strings.TrimSpace(m[1])
if claim == "" || seen[claim] {
continue
}
seen[claim] = true
if !strings.Contains(artifactText, claim) {
out = append(out, claim)
}
}
return out
}
// ---- 报告 ----
func writeReport(t *testing.T, rep *report) {
t.Helper()
outPath := os.Getenv("AGENT_EVAL_OUT")
if outPath == "" {
outPath = filepath.Join("eval-live", "report-"+time.Now().Format("0102-150405")+".json")
}
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
t.Fatalf("mkdir report dir: %v", err)
}
raw, _ := json.MarshalIndent(rep, "", " ")
if err := os.WriteFile(outPath, raw, 0o644); err != nil {
t.Fatalf("write report: %v", err)
}
fmt.Fprintf(os.Stderr, "\n[live-eval] report: %s\n%s\n", outPath, renderMarkdown(rep))
}
func renderMarkdown(rep *report) string {
var b strings.Builder
fmt.Fprintf(&b, "# Live eval — %s\n\n- target: %s\n- model: %s\n- generated: %s\n\n", rep.GeneratedAt, rep.Target, rep.Model, rep.GeneratedAt)
for _, tk := range rep.Tasks {
fmt.Fprintf(&b, "## %s\n\n| turn | status | tools | fails | regens | api探查 | 读原件 | 计划 | 检查 | 工件 | 时长s |\n|---|---|---|---|---|---|---|---|---|---|---|\n", tk.ID)
for _, turn := range tk.Turns {
fmt.Fprintf(&b, "| %s | %s | %d | %d | %d | %d | %d | %d | %d | %d | %.0f |\n",
truncate(turn.User, 18), turn.Status, turn.Metrics.ToolCalls, turn.Metrics.ToolFailures,
turn.Metrics.FullRegens, turn.Metrics.APIDiscovery, turn.Metrics.ReadsOriginal,
turn.Metrics.PlanUpdates, turn.Metrics.VerifierChecks, len(turn.Artifacts),
float64(turn.DurationMs)/1000)
}
if len(tk.UnverifiedFacts) > 0 {
fmt.Fprintf(&b, "\n未核实事实(工件里出现、材料里找不到):%s\n", strings.Join(tk.UnverifiedFacts, "、"))
if len(tk.UnverifiedUnmarkedFacts) > 0 {
fmt.Fprintf(&b, "其中未标注(无出处且无目标/预计/假设标注,E3 闸门口径):%s\n", strings.Join(tk.UnverifiedUnmarkedFacts, "、"))
}
}
if len(tk.FinalVsArtifact) > 0 {
fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";"))
}
fmt.Fprint(&b, "\n")
}
return b.String()
}
+318
View File
@@ -0,0 +1,318 @@
//go:build live
// Package live 是真实后端 + 真实模型的任务级评测(Phase 0 基线)。
//
// 用法(对准一个已配置好模型的运行实例):
//
// AGENT_EVAL_BASE=http://127.0.0.1:18799 \
// AGENT_EVAL_MATERIALS=~/.agent-system/eval/materials \
// AGENT_EVAL_OUT=/tmp/before.json \
// go test ./eval/live/ -tags live -run TestLiveSuite -v -timeout 90m
//
// 评测器只用公开 HTTP 面(上传→建会话→发消息→轮询),指标从消息 parts、
// run usage 与工件内容计算;不触碰目标实例数据库。HITL 卡自动选第一个选项,
// 保证无人值守跑完。产出 JSON + Markdown 报告;任务集见 tasks.json。
// 真实材料不进仓:materials 目录按 eval/README 的口径放本机。
package live
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type task struct {
ID string `json:"id"`
Material string `json:"material,omitempty"`
Turns []string `json:"turns"`
}
type part struct {
Type string `json:"type"`
Text string `json:"text"`
Process bool `json:"process"`
ToolCall *struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
Duration float64 `json:"durationMs"`
} `json:"toolCall"`
ToolResult *struct {
CallID string `json:"callId"`
Status string `json:"status"`
Summary string `json:"summary"`
} `json:"toolResult"`
Check *struct {
Passed bool `json:"passed"`
Reason string `json:"reason"`
} `json:"check"`
Artifact *struct {
ArtifactID string `json:"artifactId"`
Name string `json:"name"`
Kind string `json:"kind"`
} `json:"artifact"`
}
type message struct {
ID string `json:"id"`
RunID string `json:"runId"`
Role string `json:"role"`
Parts []part `json:"parts"`
Excluded bool `json:"excluded"`
}
type run struct {
ID string `json:"id"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
EndedAt string `json:"endedAt"`
Usage *struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
DurationMs int `json:"durationMs"`
ToolCalls int `json:"toolCalls"`
} `json:"usage"`
Error any `json:"error"`
}
type artifactMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
MIME string `json:"mime"`
SourceRun string `json:"sourceRun"`
CreatedAt string `json:"createdAt"`
}
type hitlCard struct {
ID string `json:"id"`
Question string `json:"question"`
Choices []struct {
ID string `json:"id"`
Label string `json:"label"`
} `json:"choices"`
AllowFreeInput bool `json:"allowFreeInput"`
}
type client struct {
base string
http *http.Client
t *testing.T
model string
}
func (c *client) do(method, path string, body any, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.base+path, rd)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, truncate(string(raw), 300))
}
if out != nil {
return json.Unmarshal(raw, out)
}
return nil
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
func TestLiveSuite(t *testing.T) {
base := strings.TrimSpace(os.Getenv("AGENT_EVAL_BASE"))
if base == "" {
t.Skip("AGENT_EVAL_BASE not set; live eval requires a configured backend instance")
}
materialsDir := os.Getenv("AGENT_EVAL_MATERIALS")
if materialsDir == "" {
home, _ := os.UserHomeDir()
materialsDir = filepath.Join(home, ".agent-system", "eval", "materials")
}
if _, err := os.Stat(materialsDir); err != nil {
t.Skipf("materials dir not available: %v", err)
}
raw, err := os.ReadFile("tasks.json")
if err != nil {
t.Fatalf("read tasks.json: %v", err)
}
var tasks []task
if err := json.Unmarshal(raw, &tasks); err != nil {
t.Fatalf("parse tasks.json: %v", err)
}
if filter := os.Getenv("AGENT_EVAL_TASKS"); filter != "" {
want := map[string]bool{}
for _, id := range strings.Split(filter, ",") {
want[strings.TrimSpace(id)] = true
}
var kept []task
for _, tk := range tasks {
if want[tk.ID] {
kept = append(kept, tk)
}
}
tasks = kept
}
if len(tasks) == 0 {
t.Fatal("no tasks selected")
}
turnTimeout := 20 * time.Minute
if v := os.Getenv("AGENT_EVAL_TURN_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
turnTimeout = d
}
}
c := &client{base: base, http: &http.Client{Timeout: 120 * time.Second}, t: t}
// /api/settings 的形状是 {defaults, project, schema, user};模型播在 user 作用域。
var settings struct {
User struct {
Models struct {
Chat struct {
Model string `json:"model"`
} `json:"chat"`
} `json:"models"`
} `json:"user"`
}
_ = c.do("GET", "/api/settings", nil, &settings)
c.model = settings.User.Models.Chat.Model
proj := struct {
ID string `json:"id"`
}{}
if err := c.do("POST", "/api/projects", map[string]string{"name": "eval-" + time.Now().Format("0102-150405")}, &proj); err != nil {
t.Fatalf("create project: %v", err)
}
rep := report{Target: base, Model: c.model, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}
for _, tk := range tasks {
rep.Tasks = append(rep.Tasks, runTask(t, c, proj.ID, tk, materialsDir, turnTimeout))
}
writeReport(t, &rep)
}
type turnResult struct {
User string `json:"user"`
RunID string `json:"runId"`
Status string `json:"status"`
FinalText string `json:"finalText,omitempty"`
Usage *run `json:"usage,omitempty"`
Metrics turnMetrics `json:"metrics"`
Checks []checkRecord `json:"checks,omitempty"`
Artifacts []string `json:"artifacts,omitempty"`
Issues []string `json:"issues,omitempty"`
DurationMs int64 `json:"durationMs"`
}
type turnMetrics struct {
ToolCalls int `json:"toolCalls"`
ToolFailures int `json:"toolFailures"`
FullRegens int `json:"fullRegens"`
APIDiscovery int `json:"apiDiscovery"`
ReadsOriginal int `json:"readsOriginal"`
PlanUpdates int `json:"planUpdates"`
VerifierChecks int `json:"verifierChecks"`
}
type checkRecord struct {
Passed bool `json:"passed"`
Reason string `json:"reason"`
}
type taskResult struct {
ID string `json:"id"`
Turns []turnResult `json:"turns"`
Total turnMetrics `json:"total"`
UnverifiedFacts []string `json:"unverifiedFacts,omitempty"`
// UnverifiedUnmarkedFacts:无出处且未按「目标/预计/假设」标注的子集
// (E3 口径:严格口径 unverifiedFacts 保留原义以维持历史可比性)。
UnverifiedUnmarkedFacts []string `json:"unverifiedUnmarkedFacts,omitempty"`
FinalVsArtifact []string `json:"finalVsArtifact,omitempty"`
}
type report struct {
Target string `json:"target"`
Model string `json:"model,omitempty"`
GeneratedAt string `json:"generatedAt"`
Tasks []taskResult `json:"tasks"`
}
func runTask(t *testing.T, c *client, projectID string, tk task, materialsDir string, turnTimeout time.Duration) taskResult {
t.Helper()
conv := struct {
ID string `json:"id"`
}{}
if err := c.do("POST", "/api/projects/"+projectID+"/conversations", map[string]string{"title": "eval-" + tk.ID}, &conv); err != nil {
t.Fatalf("[%s] create conversation: %v", tk.ID, err)
}
var attachmentIDs []string
materialText := ""
if tk.Material != "" {
path := filepath.Join(materialsDir, tk.Material)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("[%s] open material: %v", tk.ID, err)
}
up := c.upload(t, projectID, tk.Material, raw)
attachmentIDs = append(attachmentIDs, up)
materialText = docxTextIfDocx(t, tk.Material, raw)
}
tr := taskResult{ID: tk.ID}
for i, turnText := range tk.Turns {
tr.Turns = append(tr.Turns, runTurn(t, c, projectID, conv.ID, turnText, attachmentIDs, turnTimeout))
attachmentIDs = nil // 材料只在第一轮附带;后续轮考验跨轮上下文
_ = i
}
// 汇总
for _, turn := range tr.Turns {
tr.Total.ToolCalls += turn.Metrics.ToolCalls
tr.Total.ToolFailures += turn.Metrics.ToolFailures
tr.Total.FullRegens += turn.Metrics.FullRegens
tr.Total.APIDiscovery += turn.Metrics.APIDiscovery
tr.Total.ReadsOriginal += turn.Metrics.ReadsOriginal
tr.Total.PlanUpdates += turn.Metrics.PlanUpdates
tr.Total.VerifierChecks += turn.Metrics.VerifierChecks
}
// 事实核查:最后一个带工件的回合 vs 材料
if materialText != "" {
last := lastArtifactTurn(tr)
if last != nil && len(last.Artifacts) > 0 {
artText := ""
for _, name := range last.Artifacts {
artText += downloadArtifactText(t, c, projectID, name)
}
tr.UnverifiedFacts = unverifiedFacts(artText, materialText)
tr.UnverifiedUnmarkedFacts = unmarkedFacts(tr.UnverifiedFacts, artText)
tr.FinalVsArtifact = finalVsArtifact(last, artText)
}
}
return tr
}
+26
View File
@@ -0,0 +1,26 @@
[
{
"id": "minutes-basic",
"material": "meeting-rd-agent-asr.docx",
"turns": ["帮我总结一份会议纪要"]
},
{
"id": "plan-followup",
"material": "meeting-rd-agent-asr.docx",
"turns": [
"帮我总结一份会议纪要",
"根据这个纪要,制定一个方案",
"方案太简要了,给我一个详细的方案和计划"
]
},
{
"id": "qa-citation",
"material": "meeting-rd-agent-asr.docx",
"turns": ["这次会议最主要的事情是什么"]
},
{
"id": "minutes-pm-tool",
"material": "meeting-pm-tool-asr.docx",
"turns": ["生成会议纪要"]
}
]
+263 -16
View File
@@ -30,10 +30,12 @@ import (
"agent-native/runtime/telemetry"
agenttools "agent-native/runtime/tools"
nativeadp "agent-squad/adapters/native"
"agent-system-backend/internal/blobstore"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/config"
"agent-system-backend/internal/genui"
"agent-system-backend/internal/hostapi"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsem"
@@ -50,6 +52,7 @@ import (
"agent-system-backend/internal/skills"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
core "agent-system-core"
@@ -76,6 +79,68 @@ type Options struct {
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
// 零值=false=现状。
DisableExecTools bool
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
// 硬关闭 DisableExecTools 优先级高于后端选择。
ExecBackend string
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
ExecDepsDir string
// ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有
// upper+CAS 回收);空 = EXT-16 现状直写会话目录。
ExecWorkspace string
// Tier 部署档(协作存储 KTD1):空=auto(DSN→PG 否则 SQLite);local=SQLite;
// team=PG fail-closed(缺 DSN/未认证拒绝启动;PG 故障拒绝就绪,不回落 SQLite)。
Tier string
// PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。
PGPool store.PoolOptions
}
// instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异),
// 本地档退化为 local(单实例语义)。
func instanceTag(opt Options) string {
if opt.Tier == "team" || opt.StorageDSN != "" {
return hostTag() + ":" + opt.Addr
}
return "local"
}
func hostTag() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "host"
}
return h
}
// tierName 输出部署档名(诊断统一拼写:空 = auto)。
func tierName(opt Options) string {
if opt.Tier == "" {
return "auto"
}
return opt.Tier
}
// TierRefusal 部署档门禁(KTD1):返回阻止启动的问题(空 = 放行)。
// team 档 fail-closed:缺 DSN、未开认证拒绝;PG 故障的拒绝发生在其后的
// OpenPostgres(Run 对 team 档的错误显式声明不回落 SQLite)。
func TierRefusal(opt Options) []string {
return config.Resolved{Config: config.Config{
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, Tier: opt.Tier,
PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
}}.TierProblems()
}
// latestVersion 返回已应用迁移的最高版本(0 = 空账本)。
func latestVersion(versions []int) int {
max := 0
for _, v := range versions {
if v > max {
max = v
}
}
return max
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -84,13 +149,25 @@ func Run(opt Options) error {
Service: "agent-system", Module: "app", Level: logsys.LevelInfo,
Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}},
})
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir)
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir, "tier", tierName(opt))
resolved := config.Resolved{Config: config.Config{
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen,
Tier: opt.Tier, PGMaxOpen: opt.PGPool.MaxOpenConn, PGMaxIdle: opt.PGPool.MaxIdleConn,
PGConnMaxIdleSec: opt.PGPool.ConnMaxIdleTimeSec, PGConnMaxLifetimeSec: opt.PGPool.ConnMaxLifetimeSec,
PGQueryTimeoutSec: opt.PGPool.QueryTimeoutSec,
}}
if msg := resolved.ListenRefusal(); msg != "" {
return fmt.Errorf("listen guard: %s", msg)
}
// 部署档门禁(KTD1 fail-closed):team 档缺 DSN/未开认证直接拒绝启动;
// local 档带 DSN 是配置错误。此处与 config.TierProblems 同规则(纵深防御,
// 服务入口不依赖 CLI 是否校验)。
if msgs := TierRefusal(opt); len(msgs) > 0 {
return fmt.Errorf("tier guard: %s", strings.Join(msgs, ";"))
}
if opt.Tier == "team" && !opt.DisableExecTools && opt.ExecBackend == "" {
log.Printf("warning: team 档未配置执行隔离(disableExecTools 或 execBackend);向真实多用户开放前必须补齐(方案 KTD1)")
}
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
}
@@ -101,7 +178,20 @@ func Run(opt Options) error {
if artifacts == "" {
artifacts = filepath.Join(opt.DataDir, "artifacts")
}
st, err = store.OpenPostgres(opt.StorageDSN, artifacts)
// 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享);
// 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。
blobs, berr := blobstore.NewS3FromEnv()
if berr != nil {
return fmt.Errorf("blobstore s3: %w", berr)
}
if blobs != nil {
log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix)
}
st, err = store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs)
if err != nil && opt.Tier == "team" {
// team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。
return fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err)
}
} else {
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
}
@@ -109,7 +199,30 @@ func Run(opt Options) error {
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
log.Printf("storage tier: %s", st.Dialect())
// U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐;
// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。
if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" {
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
if opt.StorageDSN != "" {
dbref = "pg"
} else {
dbref = "sqlite"
}
}
if err := st.VerifySystemIdentity(sysID, dbref); err != nil {
return fmt.Errorf("system identity: %w", err)
}
}
pool := st.Pool()
if versions, verr := st.MigrationStatus(); verr == nil {
log.Printf("storage tier: %s (dialect=%s pool=%d/%d idleSec=%d lifetimeSec=%d schema=v%d)",
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, pool.ConnMaxIdleTimeSec,
pool.ConnMaxLifetimeSec, latestVersion(versions))
} else {
log.Printf("storage tier: %s (dialect=%s pool=%d/%d schema=? migration status unavailable: %v)",
tierName(opt), st.Dialect(), pool.MaxOpenConn, pool.MaxIdleConn, verr)
}
b := bus.New()
reg := tools.NewRegistry()
@@ -128,6 +241,27 @@ func Run(opt Options) error {
if opt.DisableExecTools {
log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)")
}
// EXT-16:执行后端选择(direct|sandbox);未知名字启动即失败(fail-closed)。
if err := tools.SetExecBackend(opt.ExecBackend); err != nil {
return fmt.Errorf("exec backend: %w", err)
}
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
}
if opt.ExecDepsDir != "" {
tools.SetExecDepsDir(opt.ExecDepsDir)
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
}
if opt.ExecWorkspace == "overlay" {
tools.SetWorkspaceOverlay(true)
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: workspace=overlay (frozen base + private upper + CAS apply)")
} else {
log.Printf("exec tools: workspace=overlay ignored (backend is not sandbox)")
}
} else if opt.ExecWorkspace != "" {
return fmt.Errorf("exec workspace: unknown mode %q (overlay)", opt.ExecWorkspace)
}
if err := tools.RegisterExecTools(reg); err != nil {
return fmt.Errorf("exec tools: %w", err)
}
@@ -166,18 +300,22 @@ func Run(opt Options) error {
rag.SecretLookup = func(service, fallback string) string {
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
}
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
eff := setSvc.Effective(store.OwnerDefault, "")
tavilyRaw := eff.GetString("tools.tavily.apiKeys", "")
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyRaw = active
}
if keys := splitKeyPool(tavilyRaw); len(keys) > 0 {
provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys)
if err := tools.RegisterSearchTools(reg, provider); err != nil {
return fmt.Errorf("search tools: %w", err)
// 网络搜索(Tavily):始终注册,密钥按调用时读取(设置页/svc 播种改了
// 即刻生效——此前启动时快照密钥,播种后必须重启工具才注册)。
// 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
effNow := setSvc.Effective(store.OwnerDefault, "")
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyNow = active
}
log.Printf("tools: tavily_search/web_search registered (%d key(s))", len(keys))
keys := splitKeyPool(tavilyNow)
if len(keys) == 0 {
return nil
}
return agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
}); err != nil {
return fmt.Errorf("search tools: %w", err)
}
promptLib, err := prompts.DefaultLibrary()
if err != nil {
@@ -378,18 +516,82 @@ func Run(opt Options) error {
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
log.Printf("surface: recovered %d pending events", n)
}
}()
mgr.SetCards(cardSvc)
// 执行身份与命令消费(协作存储 U5):PG 档启动跨实例命令轮询;
// SQLite 本地档无跨实例命令(StartCommandConsumer 自行短路)。
mgr.SetInstanceTag(instanceTag(opt), opt.Addr)
stopCommands := mgr.StartCommandConsumer(context.Background())
defer stopCommands()
// 执行预算(协作存储 U3,R8):runtime.execBudgetGlobal / runtime.execBudgetPerUser
// 全零=不限(本地档默认,行为不变);开启后执行许可共享账本生效,
// 待执行队列上限 runtime.execQueueMax(默认 200,容量合同数值待 U7 实测调整)。
mgr.SetExecutionBudgetFn(func() store.ExecutionBudget {
eff := setSvc.Effective(store.OwnerDefault, "")
return store.ExecutionBudget{
Global: int(eff.GetNumber("runtime.execBudgetGlobal", 0)),
PerUser: int(eff.GetNumber("runtime.execBudgetPerUser", 0)),
}
}, int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.execQueueMax", 200)))
// 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
// U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发
//(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。
surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) {
resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params,
Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID})
state := "committed"
if resp.Receipt.State != "" {
state = string(resp.Receipt.State)
}
return surfacesvc.ActionOutcome{State: state, CardID: resp.Card.ID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
})
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
bus.SetToolGate(func(owner, commandID string) bool {
if !strings.HasPrefix(commandID, genui.PluginID+".") {
return true
}
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
})
// 自动建议页面 opt-in:settings ∧ activation;预算/冷却在 genui.Auto。
mgr.SetGenUIAuto(genui.NewAuto(
func(owner string) bool {
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
},
func(owner string) bool {
return setSvc.Effective(owner, "").GetBool(genui.SettingAutoPresent, false)
},
func(owner, kind, detail string) {
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: kind, Evidence: detail})
},
))
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
authzSlot := &projectAuthz{}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
ok, err := authzSlot.Authorize(owner, projectID)
return err == nil && ok
})
// EXT-14:命令即工具——带 exposeAsTool 的命令投影进回合工具清单,模型调用
// 经命令总分发(Entrance=agent,同审计信封与高危内联确认卡)。
mgr.SetCommandSource(busAdapter{bus: bus})
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
wireScopedHostFactory(caps)
applyCompiled(skillsRoot, bus, caps)
compiledSummary := applyCompiled(skillsRoot, bus, caps)
mgr.OnUsage = func(owner string, tokens int) {
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
status, aerr := st.AccountTokens(owner, tokens, budget)
@@ -442,6 +644,9 @@ func Run(opt Options) error {
srv.Workflows = wfReg
mgr.SetWorkflows(wfReg)
srv.Cards = cardSvc
srv.Surfaces = surfaceSvc
// WP3-I4:编译插件摘要进 /api/plugins 管理清单(注册面即清单面)。
srv.CompiledPlugins = compiledSummary
srv.Bus = bus
srv.Gate = gate
srv.Prompts = promptSvc
@@ -625,6 +830,20 @@ func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
return st.RevokeAPIKeyByName(owner, name)
}
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
return withStore(storageDSN, dataDir, func(st *store.Store) error {
return st.EnsureSystemIdentity(instanceID, dbref)
})
}
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
return withStore(storageDSN, dataDir, func(st *store.Store) error {
return st.VerifySystemIdentity(instanceID, dbref)
})
}
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
func cachedInt(ttl time.Duration, fn func() int) func() int {
var mu sync.Mutex
@@ -697,3 +916,31 @@ func splitKeyPool(s string) []string {
}
return out
}
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
type busAdapter struct{ bus *cmdbus.Bus }
func (a busAdapter) CommandTools() []runmgr.CommandTool {
return a.CommandToolsFor("")
}
func (a busAdapter) CommandToolsFor(owner string) []runmgr.CommandTool {
src := a.bus.CommandToolsFor(owner)
out := make([]runmgr.CommandTool, len(src))
for i, ct := range src {
out[i] = runmgr.CommandTool{ID: ct.ID, Description: ct.Description, Params: ct.Params, Risk: ct.Risk}
}
return out
}
func (a busAdapter) DispatchCommand(ctx context.Context, req runmgr.CommandRequest) runmgr.CommandResponse {
resp := a.bus.Dispatch(ctx, cmdbus.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
})
return runmgr.CommandResponse{
State: string(resp.Receipt.State), Reason: resp.Receipt.Reason,
Payload: resp.Payload, Card: resp.Card != nil,
}
}
+120 -4
View File
@@ -11,6 +11,7 @@ import (
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/server"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
@@ -26,6 +27,29 @@ type pluginRegistry struct {
compiledRegistry
pluginID string
caps hostcaps
// summary 是本插件的注册面摘要(WP3-I4):AddAdminZone 声明记录于此,
// server 经 CompiledPlugins 供给分区清单端点(WP3-I3)。
summary *server.CompiledPlugin
}
// AddAdminZone 记录管理区分区声明(WP3-I3):声明即展示元数据,权威记录
// 就是注册期摘要;规格非法(非对象、id/title 缺失)fail-closed 拒绝,
// 使 register() 整体中止(与命令注册同一失败语义)。
func (r pluginRegistry) AddAdminZone(spec json.RawMessage) error {
if r.summary == nil {
return errors.New("extension: " + r.pluginID + ": admin zone outside applyCompiled")
}
var s extension.AdminZoneSpec
if err := json.Unmarshal(spec, &s); err != nil {
return fmt.Errorf("extension: admin zone spec: %w", err)
}
if s.ID == "" || s.Title == "" {
return fmt.Errorf("extension: %s: admin zone requires id and title", r.pluginID)
}
r.summary.AdminZones = append(r.summary.AdminZones, server.AdminZoneSection{
ID: s.ID, Title: s.Title, Icon: s.Icon, Desc: s.Desc, Order: s.Order,
})
return nil
}
func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
@@ -40,14 +64,30 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
return fmt.Errorf("extension: command spec: %w", err)
}
return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) {
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner}
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}
return h(ctx, host, extension.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
HostProjectID: req.HostProjectID,
})
})
}
// AddIntent 意图声明(G2-2):汇入总线意图表;插件身份随声明归因。
func (r pluginRegistry) AddIntent(spec json.RawMessage) error {
if r.bus == nil {
return nil
}
var is extension.IntentSpec
if err := json.Unmarshal(spec, &is); err != nil {
return fmt.Errorf("extension: intent spec: %w", err)
}
return r.bus.Intents().Declare(cmdbus.Intent{
ID: is.ID, Phrases: is.Phrases, Command: is.Command,
Params: is.Params, Description: is.Description, Plugin: r.pluginID,
})
}
func (r compiledRegistry) AddSkill(name, markdown string) error {
if r.skillsRoot == "" || name == "" {
return nil
@@ -71,22 +111,98 @@ func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorize
return errors.New("extension: declarative plugins cannot register resource authorizers")
}
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
// 拒绝,register() 整体中止,插件命令全部未注册(RDM 二进制 rdm.* 全部
// 「unknown command」;MS1 重验收烟测实测发现)。
func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
if fn == nil {
return errors.New("extension: " + r.pluginID + ": nil resource authorizer")
}
r.caps.authz.set(r.pluginID, fn)
return nil
}
func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
return nil
}
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) {
// recordRegistry 包装 per-plugin 注册表,把成功注册的声明资产名记入摘要
// (WP3-I4:注册面即清单面——编译插件的管理条目不另设声明口)。
type recordRegistry struct {
extension.Registry
sum *server.CompiledPlugin
}
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
if err := r.Registry.AddCommand(spec); err != nil {
return err
}
if id := specID(spec); id != "" {
r.sum.Commands = append(r.sum.Commands, id)
}
return nil
}
func (r recordRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
if err := r.Registry.AddCommandHandler(spec, h); err != nil {
return err
}
if id := specID(spec); id != "" {
r.sum.Commands = append(r.sum.Commands, id)
}
return nil
}
func (r recordRegistry) AddIntent(spec json.RawMessage) error {
if err := r.Registry.AddIntent(spec); err != nil {
return err
}
if id := specID(spec); id != "" {
r.sum.Intents = append(r.sum.Intents, id)
}
return nil
}
func (r recordRegistry) AddSkill(name, markdown string) error {
if err := r.Registry.AddSkill(name, markdown); err != nil {
return err
}
r.sum.Skills = append(r.sum.Skills, name)
return nil
}
// specID 宽松提取 spec 顶层 id:仅服务清单摘要展示,解析失败返回空串
// 不记录,不影响注册结果本身。
func specID(spec json.RawMessage) string {
var s struct {
ID string `json:"id"`
}
if err := json.Unmarshal(spec, &s); err != nil {
return ""
}
return s.ID
}
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.CompiledPlugin {
base := compiledRegistry{skillsRoot: skillsRoot, bus: bus}
var out []server.CompiledPlugin
for _, e := range extension.Compiled() {
if e.Register == nil {
continue
}
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps}
if err := e.Register(reg); err != nil {
sum := server.CompiledPlugin{ID: e.ID}
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps, summary: &sum}
// Register 失败的插件保留已成功注册部分(与命令总线一致:部分注册
// 生效),摘要如实反映实际注册面。
if err := e.Register(recordRegistry{Registry: reg, sum: &sum}); err != nil {
log.Printf("extension: %s: %v", e.ID, err)
}
out = append(out, sum)
}
return out
}
func mergeMCPServers(raw string, extra []core.PluginMCPServer) string {
+55
View File
@@ -0,0 +1,55 @@
package app
import (
"context"
"testing"
"time"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// fakeExtAuthz 进程内扩展:注册 EXT-8 适配器+一条命令——修复前适配器注册被
// 声明式分支拒绝,register() 中止导致命令全部丢失。
type fakeExtAuthz struct{}
func (f *fakeExtAuthz) Register(reg extension.Registry) error {
if err := reg.RegisterResourceAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "alice", nil
}); err != nil {
return err
}
return reg.AddCommandHandler([]byte(`{"id":"t.authz.ping","title":"t"}`),
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
return map[string]any{"ok": true}, nil
})
}
func TestInprocPluginRegistersAuthorizerAndCommands(t *testing.T) {
extension.ResetCompiled()
extension.RegisterCompiled(extension.InProc{ID: "t", Register: (&fakeExtAuthz{}).Register})
defer extension.ResetCompiled()
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
caps := hostcaps{st: st, authz: &projectAuthz{}}
applyCompiled("", bus, caps)
// ① 命令已注册且可分发(修复前:适配器注册被拒 → register() 中止 →
// 命令全部丢失,rdm.* 全部 unknown command)。
if resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "t.authz.ping", Entrance: "button", Owner: "alice"}); resp.Receipt.State != "committed" {
t.Fatalf("命令分发 = %+v(注册整体中止)", resp.Receipt)
}
// ② 适配器经共享槽生效:alice 放行、bob 拒绝。
if ok, _ := caps.authz.Authorize("alice", "p1"); !ok {
t.Fatal("alice 应放行")
}
if ok, _ := caps.authz.Authorize("bob", "p1"); ok {
t.Fatal("bob 应被拒绝")
}
}
+92
View File
@@ -0,0 +1,92 @@
package app
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
// WP3-I4:applyCompiled 的注册面摘要——成功注册的命令/意图/技能记入
// CompiledPlugin,失败注册不记录,Register 整体失败保留已成功部分。
func TestApplyCompiledRecordsSummary(t *testing.T) {
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
extension.RegisterCompiled(extension.InProc{ID: "demo", Register: func(reg extension.Registry) error {
if err := reg.AddCommandHandler(json.RawMessage(`{"id":"demo.one","title":"一"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
return "ok", nil
}); err != nil {
return err
}
// 重复 id 注册失败:总线拒绝,摘要不记录。
if err := reg.AddCommand(json.RawMessage(`{"id":"demo.one","title":"重复"}`)); err == nil {
t.Fatal("duplicate command should fail")
}
if err := reg.AddIntent(json.RawMessage(`{"id":"demo.go","phrases":["演示"],"command":"demo.one"}`)); err != nil {
return err
}
_ = reg.AddSkill("demo-skill", "# 演示\n技能正文")
// WP3-I3:管理区分区声明记入摘要;缺 id/title 的非法规格 fail-closed。
if err := reg.AddAdminZone(json.RawMessage(`{"id":"demo.audit","title":"演示审计","icon":"search","order":20}`)); err != nil {
return err
}
if err := reg.AddAdminZone(json.RawMessage(`{"title":"缺 id"}`)); err == nil {
t.Fatal("admin zone without id should fail")
}
return nil
}})
extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error {
if err := reg.AddCommand(json.RawMessage(`{"id":"broken.cmd","title":"先成功"}`)); err != nil {
return err
}
return reg.AddCommandHandler(json.RawMessage(`not-json`), nil)
}})
// 无 Register 的登记不产生条目。
extension.RegisterCompiled(extension.InProc{ID: "empty"})
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
sums := applyCompiled(t.TempDir(), bus, hostcaps{st: st, set: settings.NewService(st)})
if len(sums) != 2 {
t.Fatalf("want 2 summaries, got %d: %+v", len(sums), sums)
}
if sums[0].ID != "demo" {
t.Fatalf("sums[0] = %+v", sums[0])
}
if len(sums[0].Commands) != 1 || sums[0].Commands[0] != "demo.one" {
t.Fatalf("demo commands = %v", sums[0].Commands)
}
if len(sums[0].Intents) != 1 || sums[0].Intents[0] != "demo.go" {
t.Fatalf("demo intents = %v", sums[0].Intents)
}
if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" {
t.Fatalf("demo skills = %v", sums[0].Skills)
}
// WP3-I3:合法声明记入,非法规格(缺 id)被拒后摘要不受污染。
if len(sums[0].AdminZones) != 1 {
t.Fatalf("demo admin zones = %+v", sums[0].AdminZones)
}
if z := sums[0].AdminZones[0]; z.ID != "demo.audit" || z.Title != "演示审计" || z.Icon != "search" || z.Order != 20 {
t.Fatalf("demo admin zone = %+v", z)
}
// broken:Register 报错但先成功的命令仍在总线,摘要如实保留。
if sums[1].ID != "broken" || len(sums[1].Commands) != 1 || sums[1].Commands[0] != "broken.cmd" {
t.Fatalf("broken summary = %+v", sums[1])
}
// 摘要与总线一致:记录的命令真实可分发(不是空头衔)。
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "demo.one", Owner: "alice", Entrance: "test"})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("demo.one dispatch = %s (%s)", resp.Receipt.State, resp.Receipt.Reason)
}
}
+69
View File
@@ -0,0 +1,69 @@
package app
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):进程内 Host.KnowledgeSearch
// 的检索面在适配器放行后吃到他人项目的共享知识;未放行保持本人两路、零泄露。
import (
"context"
"path/filepath"
"testing"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// newExt13Host EXT-13 装配:真实 store + rag 管线 + authz 槽 + inprocHost(bob 视角)。
func newExt13Host(t *testing.T) (interface {
KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error)
}, *projectAuthz) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
pipe := rag.NewPipeline(st, set, nil, nil)
authz := &projectAuthz{}
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set, authz: authz}, pluginID: "rdm", owner: "bob"}
// alice 已发布的项目知识(带 ProjectID;模拟发布状态机产物)。
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", ProjectID: "p-1",
Source: "plugin:rdm", Ref: "ext13-1", Chunk: "EXT13共享独占令牌陈述"}); err != nil {
t.Fatal(err)
}
return h, authz
}
func TestInprocHostKnowledgeSearchShared(t *testing.T) {
h, authz := newExt13Host(t)
ctx := context.Background()
q := core.RetrievalQuery{Text: "EXT13共享独占令牌", TopK: 5}
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
t.Fatalf("before grant: hits=%d err=%v, want 0(未放行零泄露)", len(hits), err)
}
// 适配器放行 bob × p-1 → Host 检索面吃到 alice 的共享命中。
// (槽为首个注册者生效——切换授权改闭包内变量,不重注册。)
allowBob := true
authz.set("rdm", func(owner string, projectID core.ID) (bool, error) {
return allowBob && owner == "bob" && string(projectID) == "p-1", nil
})
hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("after grant: host search got 0 shared hits, want ≥1(EXT-13)")
}
// 指定他人 ProjectID 时同样经适配器(发布边界在下游状态机)。
if hits, err := h.KnowledgeSearch(ctx, "p-1", q.Text, q.TopK); err != nil || len(hits) == 0 {
t.Fatalf("scoped to shared project: hits=%d err=%v, want ≥1", len(hits), err)
}
// 撤销(同一注册内翻转授权)→ 共享命中即停、零泄露。
allowBob = false
if hits, err := h.KnowledgeSearch(ctx, "", q.Text, q.TopK); err != nil || len(hits) != 0 {
t.Fatalf("after revoke: hits=%d err=%v, want 0", len(hits), err)
}
}
+62
View File
@@ -0,0 +1,62 @@
package app
// EXT-18 AdminProvider 正负例:admin 主体 → true;非 admin / 空 actor(scoped
// host)→ false;store 查询失败 → false(fail-closed)。
import (
"testing"
"agent-system-backend/internal/store"
)
func TestExt18AdminProviderPositive(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: "boss"}
if !h.IsAdmin() {
t.Fatal("admin 主体应返回 true")
}
}
func TestExt18AdminProviderNegative(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("user", "User", "pw", false); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "user", actor: "user"}
if h.IsAdmin() {
t.Fatal("非 admin 主体应返回 false")
}
}
func TestExt18AdminProviderEmptyActor(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "Boss", "pw", true); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "boss", actor: ""} // scoped host(无 dispatch)
if h.IsAdmin() {
t.Fatal("空 actor(非 dispatch 上下文)恒 false")
}
}
func TestExt18RequestHostProjectIDField(t *testing.T) {
// 编译期断言:Request 有 HostProjectID 字段。
_ = ""
var r struct{ HostProjectID string }
r.HostProjectID = "proj-1"
if r.HostProjectID != "proj-1" {
t.Fatal("字段赋值失败")
}
}
+87 -5
View File
@@ -11,6 +11,7 @@ import (
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
core "agent-system-core"
)
@@ -18,10 +19,11 @@ import (
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
@@ -61,8 +63,25 @@ func wireScopedHostFactory(caps hostcaps) {
}
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
// sharedRetriever EXT-13:rag.Pipeline.RetrieveShared 形状(避免 app 直接 import rag
// 的具体类型断言散落)。
type sharedRetriever interface {
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
}
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK})
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
if c.authz != nil {
if rs, ok := interface{}(c.pipe).(sharedRetriever); ok {
return rs.RetrieveShared(ctx, core.ID(owner), q, func(docOwner, pid core.ID) bool {
ok, err := c.authz.Authorize(owner, pid)
return err == nil && ok
})
}
}
return c.pipe.Retrieve(ctx, core.ID(owner), q)
}
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
@@ -96,15 +115,32 @@ func (c hostcaps) parse(ctx context.Context, filename, mime string, content []by
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
type inprocHost struct {
caps hostcaps
pluginID string
owner string
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
}
func (h inprocHost) PluginID() string { return h.pluginID }
func (h inprocHost) Owner() string { return h.owner }
// IsAdmin 实现 EXT-18 AdminProvider:查询本次分发主体的基座平台管理角色。
// actor 为空(非 dispatch 上下文)恒 false;store 查询失败按非管理员处理
// (fail-closed——不因存储故障放行超管面)。
func (h inprocHost) IsAdmin() bool {
if h.actor == "" {
return false
}
state, err := h.caps.st.GetUserAuthState(h.actor)
if err != nil {
return false
}
return state.IsAdmin
}
func (h inprocHost) KVPut(key, value string) error {
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
}
@@ -213,3 +249,49 @@ func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
return h.caps.parse(ctx, filename, mime, content)
}
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
func (h inprocHost) surface() (*surfacesvc.Service, error) {
if h.caps.surfaces == nil {
return nil, surfacesvc.ErrUnavailable
}
return h.caps.surfaces, nil
}
func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
svc, err := h.surface()
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID,
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec,
})
}
func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
svc, err := h.surface()
if err != nil {
return core.SurfaceSnapshot{}, err
}
return svc.Get(ctx, h.owner, surfaceID)
}
func (h inprocHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
svc, err := h.surface()
if err != nil {
return nil, err
}
return svc.List(ctx, h.owner, conversationID)
}
func (h inprocHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
svc, err := h.surface()
if err != nil {
return false, 0, err
}
row, err := svc.Activation(ctx, h.owner, h.pluginID)
return row.Enabled, row.Epoch, err
}
+31
View File
@@ -0,0 +1,31 @@
package app
import (
"strings"
"testing"
)
// 协作存储方案 U2(KTD1/R2):服务入口的部署档门禁。
// team 档缺 DSN / 未开认证必须拒绝启动——不创建 SQLite、不进入装配。
func TestTierRefusalTeam(t *testing.T) {
msgs := TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788"})
if len(msgs) != 2 {
t.Fatalf("team without dsn/auth: %v", msgs)
}
joined := strings.Join(msgs, ";")
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
t.Fatalf("refusal wording: %v", msgs)
}
msgs = TierRefusal(Options{Tier: "team", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db", AuthMode: "on"})
if len(msgs) != 0 {
t.Fatalf("team with dsn+auth should pass: %v", msgs)
}
msgs = TierRefusal(Options{Tier: "local", Addr: "127.0.0.1:8788", StorageDSN: "postgres://u:p@h/db"})
if len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
t.Fatalf("local with dsn: %v", msgs)
}
msgs = TierRefusal(Options{Addr: "127.0.0.1:8788"})
if len(msgs) != 0 {
t.Fatalf("auto default: %v", msgs)
}
}
+121
View File
@@ -0,0 +1,121 @@
package blobstore
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// U5/KTD9:S3 兼容对象存储实现——针对嵌入式测试服务器验证
// 签名请求、Put/Get/Delete 语义与预签名 URL 生成。
// (真实 S3 兼容服务的端到端验收随 U7 压测环境执行;此处锁定协议正确性。)
func newTestS3(t *testing.T, base string) *S3 {
t.Helper()
s, err := NewS3(base, "us-east-1", "AKIDEXAMPLE", "secret", "art/")
if err != nil {
t.Fatal(err)
}
return s
}
func TestS3PutGetDeleteRoundTrip(t *testing.T) {
var gotPath, gotMethod, gotAuth string
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod, gotAuth = r.URL.Path, r.Method, r.Header.Get("Authorization")
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
gotBody = buf[:n]
switch r.Method {
case http.MethodPut:
w.WriteHeader(200)
case http.MethodGet:
if strings.Contains(gotPath, "obj404") {
w.WriteHeader(404)
return
}
w.Write([]byte("payload"))
case http.MethodDelete:
w.WriteHeader(204)
}
}))
defer srv.Close()
s := newTestS3(t, srv.URL+"/bucket")
ref, err := s.Put("doc1", []byte("payload"))
if err != nil {
t.Fatal(err)
}
if ref != "art/doc1" {
t.Fatalf("ref=%s(对象键应带前缀)", ref)
}
if gotPath != "/bucket/art/doc1" || gotMethod != http.MethodPut || string(gotBody) != "payload" {
t.Fatalf("put 请求:%s %s body=%q", gotMethod, gotPath, gotBody)
}
if len(gotAuth) == 0 || gotAuth[:17] != "AWS4-HMAC-SHA256 " {
t.Fatalf("Authorization 头:%q", gotAuth)
}
got, err := s.Get(ref)
if err != nil || string(got) != "payload" {
t.Fatalf("get: %q err=%v", got, err)
}
if err := s.Delete(ref); err != nil {
t.Fatalf("delete: %v", err)
}
// 404 → 哨兵错误(服务端对 art/obj404 的 GET 返回 404)。
if _, err := s.Get("art/obj404"); err == nil || err.Error() != ErrObjectNotFound.Error() {
t.Fatalf("404 应 ErrObjectNotFound:%v", err)
}
// Delete 幂等(404 视为成功)。
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv2.Close()
s2 := newTestS3(t, srv2.URL+"/b")
if err := s2.Delete("art/none"); err != nil {
t.Fatalf("delete 404 应幂等:%v", err)
}
}
func TestS3PresignGet(t *testing.T) {
s := newTestS3(t, "https://s3.example.com/bucket")
u, err := s.PresignGet("art/doc1", 15*time.Minute)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"X-Amz-Algorithm=AWS4-HMAC-SHA256", "X-Amz-Signature=", "X-Amz-Expires=900", "/bucket/art/doc1"} {
if !contains(u, want) {
t.Fatalf("预签名 URL 缺 %s:%s", want, u)
}
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}
func TestNewS3FromEnvFallback(t *testing.T) {
os.Unsetenv("BLOBSTORE_S3_ENDPOINT")
s, err := NewS3FromEnv()
if s != nil || err != nil {
t.Fatalf("未配置端点应回落 (nil,nil):s=%v err=%v", s, err)
}
t.Setenv("BLOBSTORE_S3_ENDPOINT", "http://x/b")
t.Setenv("BLOBSTORE_S3_ACCESS_KEY", "a")
t.Setenv("BLOBSTORE_S3_SECRET_KEY", "b")
s, err = NewS3FromEnv()
if err != nil || s == nil {
t.Fatalf("已配置应构造:s=%v err=%v", s, err)
}
}
+242
View File
@@ -0,0 +1,242 @@
package blobstore
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// S3 是 S3 兼容对象存储实现(协作存储 U5/KTD9:多实例共享工件)。
// 认证:AWS Signature V4(静态 AccessKey/SecretKey;凭据只经环境或受保护
// 配置注入,引用形态为对象键,不含凭据)。
//
// 语义(KTD9):
// - Put:先完整写对象再由上层提交引用(失败孤儿可清理);
// - Get:按对象键读取;
// - Delete:立即删除(标记/延迟 GC 由桶生命周期策略承担)。
type S3 struct {
Base string // 端点(含 bucket 路径,如 https://s3.example.com/bucket)
Region string
Access string
Secret string
Prefix string // 对象键前缀(租户/应用隔离)
client *http.Client
nowFunc func() time.Time
}
// NewS3 从显式参数构造;nil client 用默认。
func NewS3(base, region, access, secret, prefix string) (*S3, error) {
if base == "" || access == "" || secret == "" {
return nil, errors.New("blobstore: s3 requires base/access/secret")
}
if !strings.HasSuffix(base, "/") {
base += "/"
}
return &S3{Base: base, Region: region, Access: access, Secret: secret, Prefix: prefix,
client: &http.Client{Timeout: 30 * time.Second}, nowFunc: time.Now}, nil
}
// NewS3FromEnv 按环境构造:BLOBSTORE_S3_ENDPOINT/REGION/ACCESS_KEY/SECRET_KEY/PREFIX。
// 未配置端点返回 (nil, nil)(调用方回落本地档)。
func NewS3FromEnv() (*S3, error) {
if os.Getenv("BLOBSTORE_S3_ENDPOINT") == "" {
return nil, nil
}
return NewS3(os.Getenv("BLOBSTORE_S3_ENDPOINT"),
envOr("BLOBSTORE_S3_REGION", "us-east-1"),
os.Getenv("BLOBSTORE_S3_ACCESS_KEY"),
os.Getenv("BLOBSTORE_S3_SECRET_KEY"),
os.Getenv("BLOBSTORE_S3_PREFIX"))
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func (s *S3) key(id string) (string, error) {
if !validID(id) {
return "", fmt.Errorf("blobstore: invalid object id %q", id)
}
return s.Prefix + id, nil
}
// Put 上传对象;ref=对象键。
func (s *S3) Put(id string, content []byte) (string, error) {
key, err := s.key(id)
if err != nil {
return "", err
}
resp, err := s.do(http.MethodPut, key, content, "")
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return "", fmt.Errorf("blobstore: s3 put %s: %s %s", key, resp.Status, body)
}
return key, nil
}
// Get 下载对象。
func (s *S3) Get(ref string) ([]byte, error) {
resp, err := s.do(http.MethodGet, ref, nil, "")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrObjectNotFound
}
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("blobstore: s3 get %s: %s", ref, resp.Status)
}
return io.ReadAll(resp.Body)
}
// ErrObjectNotFound 对象不存在(与本地档 ErrNotExist 对齐的哨兵)。
var ErrObjectNotFound = errors.New("blobstore: object not found")
// Delete 删除对象(幂等:404 视为成功)。
func (s *S3) Delete(ref string) error {
resp, err := s.do(http.MethodDelete, ref, nil, "")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound {
return nil
}
return fmt.Errorf("blobstore: s3 delete %s: %s", ref, resp.Status)
}
// do 签名并执行请求(AWS SigV4,service=s3)。
func (s *S3) do(method, key string, body []byte, contentType string) (*http.Response, error) {
u, err := url.Parse(s.Base + key)
if err != nil {
return nil, err
}
now := s.nowFunc().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
payloadHash := sha256.Sum256(body)
payloadHex := hex.EncodeToString(payloadHash[:])
req, err := http.NewRequest(method, u.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHex)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
// 规范请求与签名(SigV4 单 chunk)。
canonicalURI := "/" + strings.TrimPrefix(u.Path, "/")
canonicalQuery := u.RawQuery
var ch bytes.Buffer
ch.WriteString(method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n")
ch.WriteString("host:" + u.Host + "\n")
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
ch.WriteString("x-amz-date:" + amzDate + "\n")
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
cr := ch.Bytes()
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(cr))
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
kRegion := hmacSHA256(kDate, s.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+s.Access+"/"+scope+
", SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="+sig)
return s.client.Do(req)
}
func sha256sum(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
func hmacSHA256(key []byte, data string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(data))
return m.Sum(nil)
}
// PresignGet 生成限时 GET 预签名 URL(下载入口;有效期由调用方指定)。
// 用于把大对象下载直接指向对象存储,应用实例不做数据中继。
func (s *S3) PresignGet(ref string, ttl time.Duration) (string, error) {
u, err := url.Parse(s.Base + ref)
if err != nil {
return "", err
}
now := s.nowFunc().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
expiry := int64(ttl / time.Second)
payloadHex := hex.EncodeToString(sha256sum(nil))
q := u.Query()
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
q.Set("X-Amz-Credential", s.Access+"/"+dateStamp+"/"+s.Region+"/s3/aws4_request")
q.Set("X-Amz-Date", amzDate)
q.Set("X-Amz-Expires", fmt.Sprint(expiry))
q.Set("X-Amz-SignedHeaders", "host")
u.RawQuery = q.Encode()
canonicalQuery := canonicalQueryEncode(u)
var ch bytes.Buffer
ch.WriteString("GET\n/" + strings.TrimPrefix(u.Path, "/") + "\n" + canonicalQuery + "\n")
ch.WriteString("host:" + u.Host + "\n")
ch.WriteString("x-amz-content-sha256:" + payloadHex + "\n")
ch.WriteString("x-amz-date:" + amzDate + "\n")
ch.WriteString("\nhost;x-amz-content-sha256;x-amz-date\n" + payloadHex)
scope := dateStamp + "/" + s.Region + "/s3/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sha256sum(ch.Bytes()))
kDate := hmacSHA256([]byte("AWS4"+s.Secret), dateStamp)
kRegion := hmacSHA256(kDate, s.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
sig := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
return u.String() + "&X-Amz-Signature=" + sig, nil
}
// canonicalQueryEncode 按 AWS 规则重排查询串(键排序 + URL 编码)。
func canonicalQueryEncode(u *url.URL) string {
q := u.Query()
var keys []string
for k := range q {
keys = append(keys, k)
}
// 排序(简单实现足够:键集固定且互不冲突)
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
var sb strings.Builder
for i, k := range keys {
if i > 0 {
sb.WriteString("&")
}
sb.WriteString(url.QueryEscape(k) + "=" + url.QueryEscape(q[k][0]))
}
return sb.String()
}
var _ Store = (*S3)(nil)
+27 -13
View File
@@ -27,14 +27,14 @@ var (
const cardAppendWindow = 250 * time.Millisecond
type Service struct {
st *store.Store
emit Emitter
st *store.Store
emit Emitter
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
MirrorConvCards bool
now func() time.Time
emitMu sync.Mutex
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
now func() time.Time
emitMu sync.Mutex
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
}
func New(st *store.Store, emit Emitter) *Service {
@@ -95,6 +95,7 @@ func (s *Service) List(owner, conversationID string) ([]core.ActivityCard, error
}
// Update 保存整卡变更(流式 Body 增量等)并发布 card.updated;ref 保持原值。
// 系统镜像路径(同会话 run 的顺序行为)沿用盲写;用户态裁决走 UpdateVersioned。
func (s *Service) Update(owner string, card core.ActivityCard) error {
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCard(owner, &card, ""); err != nil {
@@ -103,6 +104,16 @@ func (s *Service) Update(owner string, card core.ActivityCard) error {
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
}
// UpdateVersioned 乐观并发保存(U3,KTD7):版本不匹配透传 store.ErrConflict,
// HTTP 层映射 409;客户端重读卡后重试。
func (s *Service) UpdateVersioned(owner string, card core.ActivityCard, expectedVersion int) error {
card.UpdatedAt = s.now().UTC()
if err := s.st.SaveCardVersioned(owner, &card, "", expectedVersion); err != nil {
return err
}
return s.emitCard(card, core.EvCardUpdated, core.CardPayload{Card: card})
}
// Append 流式追加正文(card_append 工具;D45):文本并入 body 尾部 markdown
// 节点(尾节点非 markdown 则新增),逐次持久化;card.updated 按 cardAppendWindow
// 节流——窗口内的追加只落库不发布(投影语义:事件携带整卡,后续事件自然覆盖
@@ -141,7 +152,7 @@ func (s *Service) Append(owner, id, text string) error {
// SetStatus 状态迁移:更新并发布 card.status。
func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, err
}
@@ -149,7 +160,7 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
if tone != "" {
c.Tone = tone
}
if err := s.Update(owner, c); err != nil {
if err := s.UpdateVersioned(owner, c, ver); err != nil {
return c, err
}
return c, s.emitCard(c, core.EvCardStatus, core.CardStatusPayload{CardID: c.ID, Status: status, Tone: c.Tone, Note: note})
@@ -157,12 +168,12 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note
// SetPinned 置顶/取消(收件箱"不沉没"语义)。
func (s *Service) SetPinned(owner, id string, pinned bool) (core.ActivityCard, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, err
}
c.Pinned = pinned
return c, s.Update(owner, c)
return c, s.UpdateVersioned(owner, c, ver)
}
// Dismiss 关闭卡(非确认语义,留痕)。
@@ -173,7 +184,7 @@ func (s *Service) Dismiss(owner, id string) (core.ActivityCard, error) {
// Action 处理卡上按钮点击:仅 action_required 卡可裁决;结果 resolved 并发布
// card.resolved(ActionID 即按钮语义)。
func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCard, string, error) {
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return c, "", err
}
@@ -197,7 +208,7 @@ func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCar
return c, "", ErrNoAction
}
c.Status = core.CardResolved
if err := s.Update(owner, c); err != nil {
if err := s.UpdateVersioned(owner, c, ver); err != nil {
return c, "", err
}
return c, label, s.emitCard(c, core.EvCardResolved, core.CardResolvedPayload{CardID: c.ID, ActionID: actionID, Answer: freeText})
@@ -250,7 +261,7 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
}
return err
}
c, err := s.st.GetCard(owner, id)
c, ver, err := s.st.GetCardVersion(owner, id)
if err != nil {
return err
}
@@ -258,7 +269,10 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error {
return nil // 已决(双现任一侧先到)
}
c.Status = core.CardResolved
if err := s.Update(owner, c); err != nil {
if err := s.UpdateVersioned(owner, c, ver); err != nil {
if errors.Is(err, store.ErrConflict) {
return nil // 双现任一侧先到(版本竞争败者):不重试,卡以先到侧为准
}
return err
}
if c.ConversationID != "" && s.emit != nil {
+64
View File
@@ -34,6 +34,53 @@ type CardPort interface {
// Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。
type Handler func(ctx context.Context, req Request) (any, error)
// CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。
// Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。
type CommandTool struct {
ID string
Title string
Description string
Params json.RawMessage
Risk string
}
// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。
// ToolGate 是 owner 级工具注入闸(U3:禁用即不注入能力):返回 false 时该命令
// 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。
type ToolGate func(owner, commandID string) bool
// SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。
func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g }
func (b *Bus) CommandTools() []CommandTool {
return b.CommandToolsFor("")
}
// CommandToolsFor 按 owner 过滤工具投影(gate 为 nil 时不过滤;owner 为空时
// gated 命令一律不投影——fail-closed)。
func (b *Bus) CommandToolsFor(owner string) []CommandTool {
out := []CommandTool{}
for _, r := range b.reg.List() {
if !r.Enabled || r.Spec.ExposeAsTool == nil {
continue
}
if b.toolGate != nil && !b.toolGate(owner, r.Spec.ID) {
continue
}
risk := r.Spec.ExposeAsTool.Risk
if risk == "" {
risk = "low"
}
desc := r.Spec.Description
if desc == "" {
desc = r.Spec.Title
}
out = append(out, CommandTool{ID: r.Spec.ID, Title: r.Spec.Title,
Description: desc, Params: r.Spec.ParamsSchema, Risk: risk})
}
return out
}
// Request 一次分发请求(入口归一后的统一形态)。
type Request struct {
ID string
@@ -44,6 +91,7 @@ type Request struct {
ConversationID string
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
Confirmed bool // 危险命令经确认卡放行后的执行
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
}
// Response 分发结果:回执 + 处理负载 + 可能的确认卡。
@@ -69,6 +117,8 @@ type Bus struct {
pendMu sync.Mutex
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤)
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
@@ -84,6 +134,7 @@ func New(audit AuditStore, cards CardPort, now func() time.Time) *Bus {
return &Bus{
reg: agentcommand.NewRegistry(), audit: audit, cards: cards, now: now,
handlers: map[string]Handler{}, slash: map[string]string{}, pending: map[string]pending{},
intents: NewIntentStore(),
}
}
@@ -138,10 +189,19 @@ func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error {
if err := b.reg.Register(spec, agentcommand.SourcePlugin); err != nil {
return fmt.Errorf("cmdbus: register %s: %w", spec.ID, err)
}
b.bindPluginSlash(spec)
}
return nil
}
// bindPluginSlash EXT-10②:插件命令规格自带的 slash 别名绑定(与内置命令同一张
// 别名表——四入口同源:slash 命中即同一命令 id、同一审计信封)。
func (b *Bus) bindPluginSlash(spec agentcommand.CommandSpec) {
if spec.Slash != "" {
b.slash[strings.ToLower(spec.Slash)] = spec.ID
}
}
// RegisterPluginCommand 注册进程内插件命令(EXT-1):登记规格并绑定宿主处理器。
// 仅登记规格的声明式路径仍走 RegisterPluginCommands(无 handler,分发即 rejected)。
func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) error {
@@ -151,9 +211,13 @@ func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) er
if h != nil {
b.handlers[spec.ID] = h
}
b.bindPluginSlash(spec)
return nil
}
// Intents 意图声明表(G2-2:注册面与路由查询共用)。
func (b *Bus) Intents() *IntentStore { return b.intents }
// ResolveSlash `/` 入口解析:返回命令 id 与原始字段(参数由宿主处理器按命令语义解析)。
func (b *Bus) ResolveSlash(cmd string) (string, bool) {
id, ok := b.slash[strings.ToLower(strings.TrimSpace(cmd))]
+102
View File
@@ -0,0 +1,102 @@
package cmdbus
// EXT-14:Bus.CommandTools 只投影带 ExposeAsTool 的已启用命令;risk 缺省 low、
// description 缺省取 title;未暴露/未启用的命令不出现。
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
agentcommand "agent-command"
)
func newToolBus(t *testing.T) *Bus {
t.Helper()
return New(nopAudit{}, nil, func() time.Time { return time.Now().UTC() })
}
func TestCommandToolsFiltering(t *testing.T) {
b := newToolBus(t)
specs := []json.RawMessage{
[]byte(`{"id":"rdm.trace","title":"追溯查询","description":"查网","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"medium"}}`),
[]byte(`{"id":"rdm.baseline","title":"基线视图","exposeAsTool":{}}`),
[]byte(`{"id":"rdm.hidden","title":"未暴露","description":"不出现"}`),
}
if err := b.RegisterPluginCommands(specs); err != nil {
t.Fatal(err)
}
tools := b.CommandTools()
byID := map[string]CommandTool{}
for _, ct := range tools {
byID[ct.ID] = ct
}
if len(byID) != 2 {
t.Fatalf("projected = %+v, want 2(rdm.trace + rdm.baseline)", tools)
}
if ct := byID["rdm.trace"]; ct.Risk != "medium" || ct.Description != "查网" || string(ct.Params) != `{"type":"object"}` {
t.Fatalf("rdm.trace = %+v", ct)
}
if ct := byID["rdm.baseline"]; ct.Risk != "low" || ct.Description != "基线视图" {
t.Fatalf("rdm.baseline = %+v(risk 缺省 low、description 缺省 title)", ct)
}
if _, ok := byID["rdm.hidden"]; ok {
t.Fatal("未声明 exposeAsTool 的命令不得投影")
}
}
func TestCommandToolDispatchEnvelope(t *testing.T) {
b := newToolBus(t)
// 注册一个带处理器的暴露命令,经 Dispatch 分发验证完整回执链路。
var spec agentcommand.CommandSpec
if err := json.Unmarshal([]byte(`{"id":"rdm.echo","title":"回声","exposeAsTool":{"risk":"low"}}`), &spec); err != nil {
t.Fatal(err)
}
if err := b.RegisterPluginCommand(spec, func(ctx context.Context, req Request) (any, error) {
return map[string]any{"echo": req.Params["v"], "actor": req.Actor, "entrance": req.Entrance}, nil
}); err != nil {
t.Fatal(err)
}
resp := b.Dispatch(context.Background(), Request{ID: "rdm.echo", Params: map[string]any{"v": 1},
Entrance: "agent", Actor: "bob", Owner: "bob", ConversationID: "cnv-1"})
if resp.Receipt.State != "committed" {
t.Fatalf("receipt = %+v (%s)", resp.Receipt, resp.Receipt.Reason)
}
m, _ := resp.Payload.(map[string]any)
if m["entrance"] != "agent" || m["actor"] != "bob" {
t.Fatalf("payload = %+v(Entrance=agent 原样入审计)", m)
}
}
type nopAudit struct{}
func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil }
// U3:owner 级注入闸——gate 拒绝的命令不投影(禁用即不注入能力);fail-closed
// (owner 为空且 gate 判否时不投影)。
func TestCommandToolsForGate(t *testing.T) {
bus := New(nil, nil, func() time.Time { return time.Now().UTC() })
if err := bus.RegisterPluginCommands([]json.RawMessage{
[]byte(`{"id":"generative-ui.present","title":"生成页面","description":"d","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
[]byte(`{"id":"other.echo","title":"回声","description":"e","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"low"}}`),
}); err != nil {
t.Fatal(err)
}
bus.SetToolGate(func(owner, id string) bool {
return !strings.HasPrefix(id, "generative-ui.")
})
on := bus.CommandToolsFor("alice")
for _, ct := range on {
if strings.HasPrefix(ct.ID, "generative-ui.") {
t.Fatalf("gated command leaked for enabled owner: %+v", ct)
}
}
if len(on) != 1 {
t.Fatalf("expected only other.echo, got %+v", on)
}
if n := len(bus.CommandToolsFor("")); n != 1 {
t.Fatalf("empty owner must be fail-closed on gated commands, got %d", n)
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package cmdbuf 意图路由(G2-2:Intent 声明面与自然语言入口)。
// 插件经 extension.Registry.AddIntent 声明「短语 → 命令」映射;宿主把声明汇入
// 本表。匹配口径:文本归一(去空白/小写)后做包含匹配,最长短语优先——
// 无模型参与的确定性第一档;路由终点仍是命令总线(同审计信封与 HITL 收口)。
package cmdbus
import (
"fmt"
"sort"
"strings"
"sync"
)
// Intent 一条意图声明:短语命中后分发到 CommandID(参数模板随声明携带)。
type Intent struct {
ID string `json:"id"` // 形如 <domain>.<intent>(如 rdm.write-proposal)
Phrases []string `json:"phrases"` // 触发短语(归一后包含匹配)
Command string `json:"command"` // 目标命令 id(须已注册)
Params map[string]any `json:"params,omitempty"` // 参数模板(可被调用方覆盖)
Description string `json:"description,omitempty"`
Plugin string `json:"plugin"` // 声明方(审计归因)
}
// IntentStore 意图声明表(进程内;插件注册期写入,运行期只读)。
type IntentStore struct {
mu sync.RWMutex
byID map[string]Intent
phrases []string // 归一短语(排序后长词优先匹配)
}
// NewIntentStore 建表。
func NewIntentStore() *IntentStore {
return &IntentStore{byID: map[string]Intent{}}
}
// normalize 意图匹配归一:小写、压缩空白。
func normalize(s string) string {
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}
// Declare 登记意图(同 id 覆盖;短语与命令必填——fail-closed)。
func (s *IntentStore) Declare(in Intent) error {
if in.ID == "" || in.Command == "" || len(in.Phrases) == 0 {
return fmt.Errorf("cmdbus: intent %q 声明不完整(id/phrases/command 必填)", in.ID)
}
s.mu.Lock()
defer s.mu.Unlock()
s.byID[in.ID] = in
s.rebuild()
return nil
}
func (s *IntentStore) rebuild() {
s.phrases = s.phrases[:0]
for _, in := range s.byID {
for _, p := range in.Phrases {
s.phrases = append(s.phrases, normalize(p))
}
}
// 长短语优先:更具体的意图先命中。
sort.Slice(s.phrases, func(i, j int) bool { return len(s.phrases[i]) > len(s.phrases[j]) })
}
// Match 自然语言匹配:返回命中的意图(ok=false=未命中)。
func (s *IntentStore) Match(text string) (Intent, bool) {
n := normalize(text)
if n == "" {
return Intent{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, ph := range s.phrases {
if ph != "" && strings.Contains(n, ph) {
for _, in := range s.byID {
for _, p := range in.Phrases {
if normalize(p) == ph {
return in, true
}
}
}
}
}
return Intent{}, false
}
// List 全部意图声明(清单面)。
func (s *IntentStore) List() []Intent {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Intent, 0, len(s.byID))
for _, in := range s.byID {
out = append(out, in)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
+97
View File
@@ -0,0 +1,97 @@
package cmdbus
import (
"context"
"encoding/json"
"testing"
agentcommand "agent-command"
)
// EXT-10② 红测:插件命令规格自带 slash 别名 → 四入口同源(slash 命中同一命令
// id,经总线分发同审计信封)。
func TestPluginSlashAliasBindsAndDispatches(t *testing.T) {
bus := New(nopAudit{}, nil, nil)
called := ""
if err := bus.RegisterPluginCommand(spec(t, `{
"id": "rdm.ping", "title": "自检", "slash": "/rdm.ping"
}`), func(ctx context.Context, req Request) (any, error) {
called = req.ID + "|" + req.Entrance
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
id, ok := bus.ResolveSlash("/rdm.ping")
if !ok || id != "rdm.ping" {
t.Fatalf("ResolveSlash = %q ok=%v(插件别名未绑定)", id, ok)
}
// 大小写与首尾空白归一。
if id, ok := bus.ResolveSlash(" /RDM.PING "); !ok || id != "rdm.ping" {
t.Fatalf("归一解析失败: %q %v", id, ok)
}
resp := bus.Dispatch(context.Background(), Request{ID: id, Entrance: "slash", Owner: "alice"})
if resp.Receipt.State != "committed" {
t.Fatalf("slash 分发 = %+v", resp.Receipt)
}
if called != "rdm.ping|slash" {
t.Fatalf("处理器见到 %q(须同命令 id)", called)
}
// 同一命令按钮入口分发——同源同信封。
if resp2 := bus.Dispatch(context.Background(), Request{ID: "rdm.ping", Entrance: "button", Owner: "alice"}); resp2.Receipt.State != "committed" {
t.Fatalf("button 分发 = %+v", resp2.Receipt)
}
}
// G2-2 红测:意图声明→归一包含匹配(最长短语优先)→路由到命令(chat 入口)。
func TestIntentDeclareMatchRoute(t *testing.T) {
bus := New(nopAudit{}, nil, nil)
_ = bus.RegisterPluginCommand(spec(t, `{
"id": "rdm.draft.compose", "title": "起草"
}`), func(ctx context.Context, req Request) (any, error) {
return map[string]any{"sectionId": req.Params["sectionId"]}, nil
})
if err := bus.Intents().Declare(Intent{
ID: "rdm.write-proposal", Phrases: []string{"帮我写项目建议书", "写项目建议书"},
Command: "rdm.draft.compose", Params: map[string]any{"sectionId": "sec-1"},
Plugin: "rdm",
}); err != nil {
t.Fatal(err)
}
// 未命中。
if _, ok := bus.Intents().Match("今天天气如何"); ok {
t.Fatal("无关文本不得命中")
}
// 命中(含大小写/空白归一)。
in, ok := bus.Intents().Match("请 帮我写项目建议书,尽快")
if !ok || in.ID != "rdm.write-proposal" {
t.Fatalf("匹配 = %+v ok=%v", in, ok)
}
// 路由:匹配到的命令经总线 chat 入口分发成功。
resp := bus.Dispatch(context.Background(), Request{ID: in.Command, Params: in.Params, Entrance: "chat", Owner: "alice"})
if resp.Receipt.State != "committed" {
t.Fatalf("路由分发 = %+v", resp.Receipt)
}
// 声明校验:缺短语拒绝。
if err := bus.Intents().Declare(Intent{ID: "x", Command: "y"}); err == nil {
t.Fatal("不完整声明须拒绝")
}
// 最长短语优先:更具体的意图先命中。
if err := bus.Intents().Declare(Intent{
ID: "rdm.write-competitive", Phrases: []string{"写竞品分析"},
Command: "rdm.draft.compose", Plugin: "rdm",
}); err != nil {
t.Fatal(err)
}
if in, _ := bus.Intents().Match("帮我写项目建议书"); in.ID != "rdm.write-proposal" {
t.Fatalf("长短语意图应先命中: %+v", in)
}
}
func spec(t *testing.T, raw string) agentcommand.CommandSpec {
t.Helper()
var s agentcommand.CommandSpec
if err := json.Unmarshal([]byte(raw), &s); err != nil {
t.Fatal(err)
}
return s
}
+241 -23
View File
@@ -11,6 +11,7 @@ import (
"io"
"net"
"os"
"strconv"
"strings"
)
@@ -28,6 +29,24 @@ type Config struct {
AllowedHostsRaw string `json:"allowedHosts"`
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
DisableExecTools bool `json:"disableExecTools"`
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
// 隔离档)。DisableExecTools 优先级高于后端选择。
ExecBackend string `json:"execBackend"`
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
ExecDepsDir string `json:"execDepsDir"`
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
ExecWorkspace string `json:"execWorkspace"`
// Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite);
// local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动,
// PG 故障拒绝就绪,绝不回落 SQLite)。
Tier string `json:"tier"`
// PG 连接预算(KTD12):零值走默认(16/8/300s/不限/不限);负值非法。
// PGQueryTimeoutSec>0 时以 statement_timeout 附着到 PG DSN。
PGMaxOpen int `json:"pgMaxOpen"`
PGMaxIdle int `json:"pgMaxIdle"`
PGConnMaxIdleSec int `json:"pgConnMaxIdleSec"`
PGConnMaxLifetimeSec int `json:"pgConnMaxLifetimeSec"`
PGQueryTimeoutSec int `json:"pgQueryTimeoutSec"`
}
var defaults = Config{
@@ -39,17 +58,26 @@ var defaults = Config{
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
var envKey = map[string]string{
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
"addr": "AGENT_SYSTEM_ADDR",
"data": "AGENT_SYSTEM_DATA",
"plugins": "AGENT_SYSTEM_PLUGINS",
"dsn": "AGENT_SYSTEM_DSN",
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
"auth": "AGENT_SYSTEM_AUTH",
"mock": "AGENT_SYSTEM_MOCK",
"instance": "AGENT_SYSTEM_INSTANCE",
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
"tier": "AGENT_SYSTEM_TIER",
"pgMaxOpen": "AGENT_SYSTEM_PG_MAX_OPEN",
"pgMaxIdle": "AGENT_SYSTEM_PG_MAX_IDLE",
"pgConnMaxIdleSec": "AGENT_SYSTEM_PG_CONN_MAX_IDLE_SEC",
"pgConnMaxLifetimeSec": "AGENT_SYSTEM_PG_CONN_MAX_LIFETIME_SEC",
"pgQueryTimeoutSec": "AGENT_SYSTEM_PG_QUERY_TIMEOUT_SEC",
}
// Source 配置来源层级(数值越大优先级越高)。
@@ -99,11 +127,7 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
}
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
for f := range present {
if isBoolField(f) {
setBool(&cfg, f, boolField(fc, f))
} else {
setString(&cfg, f, stringField(fc, f))
}
setField(&cfg, f, fc)
src[f] = SrcFile
}
}
@@ -116,6 +140,12 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
return Resolved{}, err
}
setBool(&cfg, f, b)
} else if isIntField(f) {
n, err := parseInt(key, v)
if err != nil {
return Resolved{}, err
}
setInt(&cfg, f, n)
} else {
setString(&cfg, f, v)
}
@@ -125,15 +155,22 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
// CLI 层:仅显式传入。
for f := range cliSet {
src[f] = SrcCLI
if isBoolField(f) {
setBool(&cfg, f, boolField(cli, f))
} else {
setString(&cfg, f, stringField(cli, f))
}
setField(&cfg, f, cli)
}
return Resolved{Config: cfg, sources: src}, nil
}
// setField 从同类型快照覆盖单个字段(文件/CLI 层复用;字段类型由字段名决定)。
func setField(dst *Config, f string, src Config) {
if isBoolField(f) {
setBool(dst, f, boolField(src, f))
} else if isIntField(f) {
setInt(dst, f, intField(src, f))
} else {
setString(dst, f, stringField(src, f))
}
}
func loadFile(path string) (Config, map[string]bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
@@ -170,6 +207,24 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.AllowedHostsRaw
case "disableExecTools":
dst = &c.DisableExecTools
case "execBackend":
dst = &c.ExecBackend
case "execDepsDir":
dst = &c.ExecDepsDir
case "execWorkspace":
dst = &c.ExecWorkspace
case "tier":
dst = &c.Tier
case "pgMaxOpen":
dst = &c.PGMaxOpen
case "pgMaxIdle":
dst = &c.PGMaxIdle
case "pgConnMaxIdleSec":
dst = &c.PGConnMaxIdleSec
case "pgConnMaxLifetimeSec":
dst = &c.PGConnMaxLifetimeSec
case "pgQueryTimeoutSec":
dst = &c.PGQueryTimeoutSec
default:
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
}
@@ -199,6 +254,14 @@ func stringField(c Config, f string) string {
return c.Instance
case "allowedHosts":
return c.AllowedHostsRaw
case "execBackend":
return c.ExecBackend
case "execDepsDir":
return c.ExecDepsDir
case "execWorkspace":
return c.ExecWorkspace
case "tier":
return c.Tier
}
return ""
}
@@ -207,6 +270,45 @@ func isBoolField(f string) bool {
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
}
func isIntField(f string) bool {
switch f {
case "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec":
return true
}
return false
}
func intField(c Config, f string) int {
switch f {
case "pgMaxOpen":
return c.PGMaxOpen
case "pgMaxIdle":
return c.PGMaxIdle
case "pgConnMaxIdleSec":
return c.PGConnMaxIdleSec
case "pgConnMaxLifetimeSec":
return c.PGConnMaxLifetimeSec
case "pgQueryTimeoutSec":
return c.PGQueryTimeoutSec
}
return 0
}
func setInt(c *Config, f string, v int) {
switch f {
case "pgMaxOpen":
c.PGMaxOpen = v
case "pgMaxIdle":
c.PGMaxIdle = v
case "pgConnMaxIdleSec":
c.PGConnMaxIdleSec = v
case "pgConnMaxLifetimeSec":
c.PGConnMaxLifetimeSec = v
case "pgQueryTimeoutSec":
c.PGQueryTimeoutSec = v
}
}
func boolField(c Config, f string) bool {
switch f {
case "insecureListen":
@@ -247,6 +349,14 @@ func setString(c *Config, f, v string) {
c.Instance = v
case "allowedHosts":
c.AllowedHostsRaw = v
case "execBackend":
c.ExecBackend = v
case "execDepsDir":
c.ExecDepsDir = v
case "execWorkspace":
c.ExecWorkspace = v
case "tier":
c.Tier = v
}
}
@@ -260,6 +370,14 @@ func parseBool(name, v string) (bool, error) {
return false, fmt.Errorf("env %s: invalid bool %q", name, v)
}
func parseInt(name, v string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, fmt.Errorf("env %s: invalid int %q", name, v)
}
return n, nil
}
// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。
func (r Resolved) Explain(w io.Writer, file string) {
if file != "" {
@@ -268,16 +386,32 @@ func (r Resolved) Explain(w io.Writer, file string) {
fmt.Fprintln(w, "config file: (none)")
}
fmt.Fprintln(w, "field value source")
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools"} {
v := stringField(r.Config, f)
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
var v string
if isBoolField(f) {
v = fmt.Sprintf("%t", boolField(r.Config, f))
} else if isIntField(f) {
n := intField(r.Config, f)
if d := intDefault(f); n == 0 && d != 0 {
v = fmt.Sprintf("%d (默认)", d)
} else {
v = fmt.Sprintf("%d", n)
}
} else {
v = stringField(r.Config, f)
}
if f == "dsn" {
v = redact(v)
}
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
}
if msgs := r.TierProblems(); len(msgs) > 0 {
for _, msg := range msgs {
fmt.Fprintf(w, "tier guard: 拒绝启动(%s)\n", msg)
}
} else {
fmt.Fprintln(w, "tier guard: 放行")
}
if msg := r.ListenRefusal(); msg != "" {
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
@@ -292,6 +426,90 @@ func (r Resolved) AuthEnabled() bool {
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
}
// intDefault 返回 int 字段的默认值(0 表示零值合法、无默认替换)。
func intDefault(f string) int {
switch f {
case "pgMaxOpen":
return DefaultPGMaxOpen
case "pgMaxIdle":
return DefaultPGMaxIdle
case "pgConnMaxIdleSec":
return DefaultPGConnMaxIdleSec
}
return 0
}
// 连接预算默认值(KTD12;与 store 层默认保持一致,两处同时修改时以测试对齐)。
const (
DefaultPGMaxOpen = 16
DefaultPGMaxIdle = 8
DefaultPGConnMaxIdleSec = 300
)
// TierProblems 部署档语义校验:返回阻止启动的问题列表(空 = 放行)。
// team 档 fail-closed:缺 DSN、未开认证直接拒绝;local 档不允许 DSN(KTD1)。
// 执行隔离在 team 档是启动警告而非拒绝(本增量先可见、U3/U5 再硬化)。
func (r Resolved) TierProblems() []string {
var msgs []string
switch r.Tier {
case "", "local", "team":
default:
msgs = append(msgs, fmt.Sprintf("未知部署档 %q(local | team)", r.Tier))
}
if r.Tier == "team" {
if r.StorageDSN == "" {
msgs = append(msgs, "team 档需要 PostgreSQL DSN(dsn / AGENT_SYSTEM_DSN);不会回落 SQLite")
}
if !r.AuthEnabled() {
msgs = append(msgs, "team 档必须开启认证(auth=on)")
}
}
if r.Tier == "local" && r.StorageDSN != "" {
msgs = append(msgs, "local 档不使用 PostgreSQL DSN(改用 team 档,或去掉 dsn)")
}
if v := r.PGMaxOpen; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgMaxOpen 不能为负(当前 %d)", v))
}
if v := r.PGMaxIdle; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgMaxIdle 不能为负(当前 %d)", v))
}
if v := r.PGConnMaxIdleSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgConnMaxIdleSec 不能为负(当前 %d)", v))
}
if v := r.PGConnMaxLifetimeSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgConnMaxLifetimeSec 不能为负(当前 %d)", v))
}
if v := r.PGQueryTimeoutSec; v < 0 {
msgs = append(msgs, fmt.Sprintf("pgQueryTimeoutSec 不能为负(当前 %d)", v))
}
if idle, open := r.EffectivePGMaxIdle(), r.EffectivePGMaxOpen(); idle > open {
msgs = append(msgs, fmt.Sprintf("pgMaxIdle(%d) 大于 pgMaxOpen(%d)", idle, open))
}
return msgs
}
// EffectivePGMaxOpen/Idle/ConnMaxIdleSec 返回连接预算有效值(零值落默认,KTD12)。
func (r Resolved) EffectivePGMaxOpen() int {
if r.PGMaxOpen > 0 {
return r.PGMaxOpen
}
return DefaultPGMaxOpen
}
func (r Resolved) EffectivePGMaxIdle() int {
if r.PGMaxIdle > 0 {
return r.PGMaxIdle
}
return DefaultPGMaxIdle
}
func (r Resolved) EffectivePGConnMaxIdleSec() int {
if r.PGConnMaxIdleSec > 0 {
return r.PGConnMaxIdleSec
}
return DefaultPGConnMaxIdleSec
}
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
func (r Resolved) AllowedHosts() []string {
var out []string
+133
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -220,3 +221,135 @@ func TestListenGuard(t *testing.T) {
t.Fatalf("auth on should pass: %+v %q", authed.Config, authed.ListenRefusal())
}
}
// ---- 协作存储方案 U2:部署档(KTD1)与连接预算(KTD12)----
// team 档 fail-closed:缺 DSN、未开认证都拒绝;DSN+auth=on 放行。
func TestTierTeamFailClosed(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
msgs := r.TierProblems()
if len(msgs) != 2 {
t.Fatalf("team without dsn/auth should refuse with 2 problems, got %v", msgs)
}
joined := msgs[0] + msgs[1]
if !strings.Contains(joined, "PostgreSQL DSN") || !strings.Contains(joined, "认证") {
t.Fatalf("refusal messages wrong: %v", msgs)
}
// 补上 DSN(auto 认证随 DSN 开启)→ 放行。
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
if err != nil {
t.Fatal(err)
}
if msgs = r.TierProblems(); len(msgs) != 0 {
t.Fatalf("team with dsn should pass, got %v", msgs)
}
// 显式关认证仍然拒绝。
r, err = Load("", noEnv, Config{Tier: "team", StorageDSN: "postgres://u:p@h/db", AuthMode: "off"}, map[string]bool{"tier": true, "dsn": true, "auth": true})
if err != nil {
t.Fatal(err)
}
if msgs = r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "认证") {
t.Fatalf("team with auth=off should refuse, got %v", msgs)
}
}
// local 档不允许 DSN;未知档名拒绝。
func TestTierLocalAndUnknown(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "local", StorageDSN: "postgres://u:p@h/db"}, map[string]bool{"tier": true, "dsn": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "local 档不使用") {
t.Fatalf("local with dsn should refuse, got %v", msgs)
}
r, err = Load("", noEnv, Config{Tier: "cluster"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "未知部署档") {
t.Fatalf("unknown tier should refuse, got %v", msgs)
}
// auto(空)档维持现状语义:无 DSN 不拒绝。
r, err = Load("", noEnv, Config{}, nil)
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 0 {
t.Fatalf("auto default should pass, got %v", msgs)
}
}
// 连接预算四源解析与环境变量整数校验;MaxIdle>MaxOpen 拒绝。
func TestPGPoolConfigLayers(t *testing.T) {
env := func(k string) (string, bool) {
switch k {
case "AGENT_SYSTEM_PG_MAX_OPEN":
return "7", true
case "AGENT_SYSTEM_PG_MAX_IDLE":
return "9", true
}
return "", false
}
f := fileWith(t, `{"pgMaxOpen":5}`)
r, err := Load(f, env, Config{PGMaxOpen: 3, PGMaxIdle: 2}, map[string]bool{"pgMaxIdle": true})
if err != nil {
t.Fatal(err)
}
// 文件 5 被 env 7 覆盖(CLI 值 3 未显式传入不参与);pgMaxIdle CLI 显式 2 胜。
if r.PGMaxOpen != 7 || r.SourceOf("pgMaxOpen") != SrcEnv {
t.Fatalf("pgMaxOpen = %d (%v), want env 7", r.PGMaxOpen, r.SourceOf("pgMaxOpen"))
}
if r.PGMaxIdle != 2 || r.SourceOf("pgMaxIdle") != SrcCLI {
t.Fatalf("pgMaxIdle = %d (%v), want CLI 2", r.PGMaxIdle, r.SourceOf("pgMaxIdle"))
}
if msgs := r.TierProblems(); len(msgs) != 0 {
t.Fatalf("2/2 pool valid, got %v", msgs)
}
// 有效值:零值落默认。
r, err = Load("", noEnv, Config{}, nil)
if err != nil {
t.Fatal(err)
}
if r.EffectivePGMaxOpen() != DefaultPGMaxOpen || r.EffectivePGMaxIdle() != DefaultPGMaxIdle || r.EffectivePGConnMaxIdleSec() != DefaultPGConnMaxIdleSec {
t.Fatalf("effective defaults wrong: %+v", r.Config)
}
// idle > open 拒绝。
r, err = Load("", noEnv, Config{PGMaxOpen: 2, PGMaxIdle: 3}, map[string]bool{"pgMaxOpen": true, "pgMaxIdle": true})
if err != nil {
t.Fatal(err)
}
if msgs := r.TierProblems(); len(msgs) != 1 || !strings.Contains(msgs[0], "pgMaxIdle(3) 大于 pgMaxOpen(2)") {
t.Fatalf("idle>open should refuse, got %v", msgs)
}
// 非法整数环境变量报错定位到变量。
bad := func(k string) (string, bool) {
if k == "AGENT_SYSTEM_PG_MAX_OPEN" {
return "many", true
}
return "", false
}
if _, err := Load("", bad, Config{}, nil); err == nil || !strings.Contains(err.Error(), "AGENT_SYSTEM_PG_MAX_OPEN") {
t.Fatalf("invalid int env should fail with var name, got %v", err)
}
}
// explain 输出包含部署档与预算行,且 tier guard 结论可见(脱敏诊断)。
func TestExplainTierAndPool(t *testing.T) {
r, err := Load("", noEnv, Config{Tier: "team"}, map[string]bool{"tier": true})
if err != nil {
t.Fatal(err)
}
var b bytes.Buffer
r.Explain(&b, "")
out := b.String()
for _, want := range []string{"tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec", "tier guard: 拒绝启动"} {
if !strings.Contains(out, want) {
t.Fatalf("explain missing %q:\n%s", want, out)
}
}
}
+103 -14
View File
@@ -36,13 +36,23 @@ type CardResolver func(id core.ID) *core.ActivityCard
// 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。
type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool)
// AssembleOptions 是组装的可选行为。零值全部关闭(向后兼容)。
type AssembleOptions struct {
// ThinkReplay 为真时,历史 assistant 消息的思考部件以 <think> 块原样回传
//(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。
ThinkReplay bool
// Surfaces 是当前会话最近保存的生成页面(U3/R6):以语义投影并入上下文,
// 使下一轮模型能解释并修改已有结构(修改走 generative-ui.present 重提)。
Surfaces []core.SurfaceSnapshot
}
// AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段;
// 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。
// prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。
// resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威
// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填
// artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver) ([]agentllm.Message, *Report) {
func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver, opts AssembleOptions) ([]agentllm.Message, *Report) {
rep := &Report{}
msgs := make([]agentllm.Message, 0, len(sys)+1)
msgs = append(msgs, sys...)
@@ -51,6 +61,17 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: prof.SystemPrompt})
rep.Items = append(rep.Items, "system(画像自定义段)")
}
if len(opts.Surfaces) > 0 {
var b strings.Builder
b.WriteString("[本会话已保存的生成页面(结构摘要;正文以预览区为准)]\n")
for _, snap := range opts.Surfaces {
b.WriteString(projectSurface(snap))
b.WriteString("\n")
}
b.WriteString("用户要求调整页面时,按结构摘要理解现状并用 generative-ui.present 提交修订(带 surfaceId 与 baseRevision)。")
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
rep.Items = append(rep.Items, fmt.Sprintf("surface(%d)", len(opts.Surfaces)))
}
maxHist := prof.Context.MaxHistoryMessages
if maxHist <= 0 {
@@ -84,7 +105,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
continue
}
if m.Role == core.RoleAssistant {
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)...)
msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled, opts)...)
continue
}
content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)
@@ -102,6 +123,16 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
}
// 当前轮:文本 + 附件解析结果 + 知识检索注入。
msgs = append(msgs, BuildCurrentUser(eff, currentUser, attachments, knowledge, rep))
fillEstimate(msgs, rep)
return msgs, rep
}
// BuildCurrentUser 组装当前轮的用户消息(文本部件 + 附件解析全文 + 知识检索)。
// 会话转录为权威上下文时,每轮只构建这一条消息,历史由转录承载——
// 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。
func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message {
var sb strings.Builder
for _, p := range currentUser.Parts {
if p.Type == core.PartText {
@@ -118,23 +149,26 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [
text := doc.PlainText()
if runes := []rune(text); len(runes) > limit {
text = string(runes[:limit]) + "…(附件超长截断)"
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
if rep != nil {
rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s 超 %d 字符截断(原文 %d 字符)", doc.Meta.Filename, limit, len(runes)))
}
}
fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text)
}
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
if rep != nil {
rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
}
}
if len(knowledge) > 0 {
sb.WriteString("\n\n[相关知识]\n")
for i, h := range knowledge {
fmt.Fprintf(&sb, "[%d] %s(来源 %s)\n%s\n", i+1, h.Doc.Title, h.Doc.Source, h.Doc.Chunk)
}
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
if rep != nil {
rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
}
}
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()})
fillEstimate(msgs, rep)
return msgs, rep
return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()}
}
// maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。
@@ -171,17 +205,24 @@ func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, reso
// 前置文本与 tool_calls 组成一条 assistant,每个调用紧跟一条 tool 结果。
// 没有结果的调用补一条失败占位,避免模型把中断当成成功。孤立的结果直接跳过。
// 被守卫降级、且后面不是工具调用的过程文本不回放。
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) []agentllm.Message {
func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int, opts AssembleOptions) []agentllm.Message {
var out []agentllm.Message
var text strings.Builder
var think strings.Builder
var calls []agentllm.ToolCall
results := map[string]core.ToolResultPart{}
seq := 0
flush := func() {
content := strings.TrimSpace(text.String())
thinkText := strings.TrimSpace(think.String())
if opts.ThinkReplay && thinkText != "" {
// 交错思考模型要求历史 assistant 原样带 <think>;只进请求,不落盘。
content = "<think>\n" + thinkText + "\n</think>\n\n" + content
}
if content == "" && len(calls) == 0 {
text.Reset()
think.Reset()
results = map[string]core.ToolResultPart{}
return
}
@@ -202,12 +243,18 @@ func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard in
})
}
text.Reset()
think.Reset()
calls = nil
results = map[string]core.ToolResultPart{}
}
for i, p := range m.Parts {
switch p.Type {
case core.PartThinking:
if strings.TrimSpace(p.Text) != "" {
think.WriteString(p.Text)
think.WriteString("\n")
}
case core.PartText:
if demotedProcessText(m.Parts, i) {
continue
@@ -381,19 +428,22 @@ func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep
// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时
// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行
// (可见即信号,不静默丢弃)。
// 占位文案不带「[产出工件 …]」方括号形态:那正是 ClaimGate 用来识别伪造调用
// 标记的模式——历史里自己产出这种格式,下一轮模型照抄就会被终轮检查误判为
// 「正文里出现了工具调用标记」。
func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string {
if a == nil {
return ""
}
if resolve == nil {
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
}
art, content, ok := resolve(a.ArtifactID)
if !ok {
return fmt.Sprintf("[产出工件 %s]\n", a.Name)
return fmt.Sprintf("此前已生成文件:%s。\n", a.Name)
}
if !utf8.Valid(content) {
return fmt.Sprintf("[产出工件 %s(二进制内容,正文见工件查看器)]\n", art.Name)
return fmt.Sprintf("此前已生成文件:%s(二进制内容,正文见工件查看器)。\n", art.Name)
}
*backfilled++
text := string(content)
@@ -401,7 +451,7 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int,
text = string(runes[:limit]) + "…(工件超长截断)"
rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit))
}
return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text)
return fmt.Sprintf("此前已生成文件 %s,内容如下:\n%s\n", art.Name, text)
}
// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。
@@ -463,3 +513,42 @@ func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm
out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()})
return out
}
// projectSurface 是 ctxasm 侧的结构摘要投影(与 surfacesvc.ProjectSurface 同义;
// 独立实现避免 ctxasm→surfacesvc 依赖边)。
func projectSurface(snap core.SurfaceSnapshot) string {
var b strings.Builder
fmt.Fprintf(&b, "- 页面 %s(修订 %d)%q", string(snap.Identity.SurfaceID), snap.Revision, snap.Spec.Title)
if snap.SemanticText != "" {
fmt.Fprintf(&b, ":%s", snap.SemanticText)
}
b.WriteString("\n 结构:")
var walk func(n core.UINode)
first := true
walk = func(n core.UINode) {
label := string(n.Type)
if n.Title != "" {
label += "(\"" + n.Title + "\")"
}
if n.ID != "" {
label += "#" + n.ID
}
if len(n.Source) > 0 {
label += "[" + strings.Join(n.Source, ",") + "]"
}
if first {
b.WriteString(label)
first = false
} else {
b.WriteString(" > " + label)
}
for _, c := range n.Children {
walk(c)
}
}
for _, n := range snap.Spec.Nodes {
walk(n)
}
b.WriteString("\n 修订此页:present 带 surfaceId=" + string(snap.Identity.SurfaceID) + " 与 baseRevision=" + fmt.Sprint(snap.Revision) + "。")
return b.String()
}
+12 -12
View File
@@ -18,7 +18,7 @@ func TestAssembleInputAttachmentLimit(t *testing.T) {
Meta: core.DocMeta{Filename: "纪要.docx"},
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}},
}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
last := msgs[len(msgs)-1]
if !strings.Contains(last.Content, "纪要.docx") {
@@ -42,7 +42,7 @@ func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) {
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}},
}
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
last := msgs[len(msgs)-1]
if got := strings.Count(last.Content, "甲"); got != 100 {
t.Errorf("injected chars = %d, want 100 (configured limit)", got)
@@ -58,7 +58,7 @@ func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) {
Meta: core.DocMeta{Filename: "b.txt"},
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}},
}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil)
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{})
if len(rep.Truncations) != 0 {
t.Errorf("under-limit doc should not truncate: %v", rep.Truncations)
}
@@ -89,7 +89,7 @@ func TestAssembleInputCardBackfill(t *testing.T) {
}
return nil
}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil)
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil, AssembleOptions{})
var assistantIn string
for _, m := range msgs {
@@ -128,7 +128,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
}},
}
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil)
msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil, AssembleOptions{})
if strings.Count(msgs[1].Content, "丁") != 100 {
t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁"))
}
@@ -137,7 +137,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
}
// 无 resolver(收件箱关闭/卡片缺失):占位可见。
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
if !strings.Contains(msgs2[1].Content, "内容见收件箱") {
t.Errorf("fallback placeholder missing: %q", msgs2[1].Content)
}
@@ -159,8 +159,8 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
}
return core.Artifact{}, nil, false
}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
if !strings.Contains(msgs[1].Content, "[产出工件 检索汇总.md]") || !strings.Contains(msgs[1].Content, "关键结论一。") {
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
if !strings.Contains(msgs[1].Content, "此前已生成文件 检索汇总.md,内容如下:") || !strings.Contains(msgs[1].Content, "关键结论一。") {
t.Errorf("artifact body not backfilled: %q", msgs[1].Content)
}
if !contains(rep.Items, "artifact(1)") {
@@ -172,7 +172,7 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}}
msgs2, rep2 := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
return art, []byte(long), true
})
}, AssembleOptions{})
if strings.Count(msgs2[1].Content, "乙") != 100 {
t.Errorf("artifact chars = %d, want 100", strings.Count(msgs2[1].Content, "乙"))
}
@@ -183,14 +183,14 @@ func TestAssembleInputArtifactBackfill(t *testing.T) {
// 二进制内容:占位不回灌乱码。
msgs3, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
return art, []byte{0xff, 0xfe, 0x00, 0x01}, true
})
}, AssembleOptions{})
if !strings.Contains(msgs3[1].Content, "二进制内容") || strings.Contains(msgs3[1].Content, "关键结论") {
t.Errorf("binary artifact should keep placeholder: %q", msgs3[1].Content)
}
// 无 resolver:占位可见。
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil)
if !strings.Contains(msgs4[1].Content, "[产出工件 检索汇总.md]") {
msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
if !strings.Contains(msgs4[1].Content, "此前已生成文件:检索汇总.md。") {
t.Errorf("artifact placeholder missing without resolver: %q", msgs4[1].Content)
}
}
+3
View File
@@ -109,6 +109,9 @@ func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm
return msgs, rep
}
// TailNotes 导出尾注渲染(转录权威模式下由调用方经 Reminders 逐轮注入)。
func TailNotes(tail Tail) []string { return tailNotes(tail) }
func tailNotes(tail Tail) []string {
var notes []string
if text := strings.TrimSpace(tail.Instructions); text != "" {
+1 -1
View File
@@ -14,7 +14,7 @@ func TestVisibleHistoryDropsExcludedAndUnselectedVariants(t *testing.T) {
{ID: "a1", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 0, Excluded: true, Parts: []core.Part{{Type: core.PartText, Text: "旧"}}},
{ID: "a2", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新"}}},
}
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil)
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil, AssembleOptions{})
blob := ""
for _, m := range msgs {
blob += m.Content
+1 -1
View File
@@ -15,7 +15,7 @@ func assistantWith(id string, parts ...core.Part) core.Message {
}
func assembleHistory(hist []core.Message, resolve ArtifactResolver) []agentllm.Message {
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
if len(msgs) == 0 {
return nil
}
+1 -1
View File
@@ -14,7 +14,7 @@ func TestSummarizeBeyondInjectsExtract(t *testing.T) {
}
current := core.Message{ID: "cur", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "现在"}}}
prof := core.AgentProfile{Context: core.ContextPolicy{MaxHistoryMessages: 2, SummarizeBeyond: true}}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil)
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil, AssembleOptions{})
joined := ""
for _, m := range msgs {
joined += m.Content
+52
View File
@@ -0,0 +1,52 @@
package ctxasm
// U3/R6:已保存页面的语义投影并入上下文——下一轮模型能看到结构、来源与修订
// 入口(present + baseRevision),可据此解释并修改已有 table/compare/tabs。
import (
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
func surfaceSnap() core.SurfaceSnapshot {
return core.SurfaceSnapshot{
Identity: core.SurfaceIdentity{SurfaceID: "surf_x", Owner: "alice", PluginID: "generative-ui", ConversationID: "c1"},
Revision: 2,
Spec: core.SurfaceSpec{Title: "选型对比", Nodes: []core.UINode{
{Type: core.UISection, ID: "n1", Title: "概览", Children: []core.UINode{
{Type: core.UIMarkdown, ID: "n1a", Source: []string{"doc:a"}, Markdown: "结论"},
}},
{Type: core.UITable, ID: "n2", Source: []string{"doc:a", "doc:b"}},
}},
SemanticText: "三工具对比页",
}
}
func TestSurfaceProjectionInContext(t *testing.T) {
msgs, rep := AssembleInput(core.AgentProfile{}, nil, nil,
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改一下"}}},
nil, nil, nil, nil, nil, nil, AssembleOptions{Surfaces: []core.SurfaceSnapshot{surfaceSnap()}})
joined := make([]string, len(msgs))
for i, m := range msgs {
joined[i] = m.Content
}
all := strings.Join(joined, "\n---\n")
for _, need := range []string{"surf_x", "修订 2", "选型对比", "section(\"概览\")#n1", "markdown#n1a[doc:a]", "table#n2", "baseRevision=2"} {
if !strings.Contains(all, need) {
t.Fatalf("projection missing %q in assembled context:\n%s", need, all)
}
}
found := false
for _, it := range rep.Items {
if strings.HasPrefix(it, "surface(") {
found = true
}
}
if !found {
t.Fatalf("report should count surface injection: %v", rep.Items)
}
_ = agentllm.RoleSystem
}
+43
View File
@@ -0,0 +1,43 @@
package ctxasm
import (
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// 交错思考模型(MiniMax 等)要求历史 assistant 原样回传 <think>;
// 关闭时不回传(默认兼容旧行为)。思考块只进请求,不落库。
func TestThinkReplayWrapsAssistantHistory(t *testing.T) {
hist := []core.Message{
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "总结"}}},
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{
{Type: core.PartThinking, Text: "先读材料。", Process: true},
{Type: core.PartText, Text: "结论是先入库。", Process: true},
{Type: core.PartToolCall, ToolCall: &core.ToolCallPart{ID: "c1", Name: "run_python", Arguments: "{}"}},
{Type: core.PartToolResult, ToolResult: &core.ToolResultPart{CallID: "c1", Status: "ok", Summary: "done"}},
{Type: core.PartText, Text: "已完成。"},
}},
}
on, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{ThinkReplay: true})
var sawThink int
for _, m := range on {
if m.Role == agentllm.RoleAssistant && strings.HasPrefix(m.Content, "<think>") {
sawThink++
if !strings.Contains(m.Content, "</think>") || !strings.Contains(m.Content, "先读材料。") {
t.Fatalf("think block malformed: %q", m.Content)
}
}
}
if sawThink == 0 {
t.Fatalf("ThinkReplay=on should inject <think> into assistant history: %+v", on)
}
off, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{})
for _, m := range off {
if m.Role == agentllm.RoleAssistant && strings.Contains(m.Content, "<think>") {
t.Fatalf("ThinkReplay=off must not inject think blocks: %q", m.Content)
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package genui
import (
"sync"
"time"
)
// AutoBudgetPerHour 是每任务(conversation)滑动一小时内允许的自动建议次数。
const AutoBudgetPerHour = 3
// AutoCooldown 是同会话两次自动触发的最小间隔。
const AutoCooldown = 60 * time.Second
// SettingAutoPresent 是 owner 级 opt-in 设置键(默认 false;与 activation 分离)。
const SettingAutoPresent = "plugins.generative-ui.autoPresent"
// AutoReminder 是注入 system-reminder 的建议文案(只引导 present,禁止改任务/点动作)。
const AutoReminder = "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
// ActivationLookup 查询 owner 的 Surface activation(generative-ui)。
type ActivationLookup func(owner string) (enabled bool)
// SettingsLookup 查询 owner 的 autoPresent opt-in。
type SettingsLookup func(owner string) (enabled bool)
// Auditor 写审计事件(耗尽等);nil = 跳过。
type Auditor func(owner, kind, detail string)
// Auto 是自动触发闸门:opt-in ∧ activation ∧ 预算 ∧ 冷却。
// 触发源由 runmgr 显式调用;present/surface/action 路径不得调用。
type Auto struct {
activation ActivationLookup
settings SettingsLookup
audit Auditor
now func() time.Time
mu sync.Mutex
budget map[string][]time.Time // convID → 触发时刻
last map[string]time.Time // convID → 上次成功放行
}
// NewAuto 构造闸门(lookup 不得为 nil)。
func NewAuto(activation ActivationLookup, settings SettingsLookup, audit Auditor) *Auto {
return &Auto{
activation: activation,
settings: settings,
audit: audit,
now: func() time.Time { return time.Now().UTC() },
budget: map[string][]time.Time{},
last: map[string]time.Time{},
}
}
// Try 尝试放行一次自动建议。ok=false 时 skip 含原因(供测试);ok=true 时调用方应注入 AutoReminder。
func (a *Auto) Try(owner, convID, reason string) (ok bool, skip string) {
if a == nil || a.activation == nil || a.settings == nil {
return false, "unconfigured"
}
if owner == "" || convID == "" {
return false, "missing_ids"
}
if !a.settings(owner) {
return false, "opt_in_off"
}
if !a.activation(owner) {
return false, "activation_off"
}
now := a.now()
a.mu.Lock()
defer a.mu.Unlock()
if t, hit := a.last[convID]; hit && now.Sub(t) < AutoCooldown {
return false, "cooldown"
}
cutoff := now.Add(-time.Hour)
kept := a.budget[convID][:0]
for _, t := range a.budget[convID] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
a.budget[convID] = kept
if len(kept) >= AutoBudgetPerHour {
if a.audit != nil {
a.audit(owner, "genui.auto_present_exhausted", "conversation="+convID+" reason="+reason)
}
return false, "budget"
}
a.budget[convID] = append(kept, now)
a.last[convID] = now
return true, ""
}
+53
View File
@@ -0,0 +1,53 @@
package genui
import (
"testing"
"time"
)
func TestAutoDefaultOff(t *testing.T) {
a := NewAuto(func(string) bool { return true }, func(string) bool { return false }, nil)
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "opt_in_off" {
t.Fatalf("want opt_in_off, got ok=%v skip=%q", ok, skip)
}
}
func TestAutoRequiresActivation(t *testing.T) {
a := NewAuto(func(string) bool { return false }, func(string) bool { return true }, nil)
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "activation_off" {
t.Fatalf("want activation_off, got ok=%v skip=%q", ok, skip)
}
}
func TestAutoBudgetAndCooldown(t *testing.T) {
var exhausted int
now := time.Unix(1_700_000_000, 0).UTC()
a := NewAuto(func(string) bool { return true }, func(string) bool { return true },
func(owner, kind, detail string) {
if kind == "genui.auto_present_exhausted" {
exhausted++
}
})
a.now = func() time.Time { return now }
for i := 0; i < AutoBudgetPerHour; i++ {
ok, skip := a.Try("alice", "c1", "plan")
if !ok {
t.Fatalf("shot %d should pass, skip=%q", i, skip)
}
now = now.Add(AutoCooldown + time.Second)
}
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "budget" || exhausted != 1 {
t.Fatalf("want budget exhausted, ok=%v skip=%q exhausted=%d", ok, skip, exhausted)
}
// 冷却:同一时刻第二次拒绝
a2 := NewAuto(func(string) bool { return true }, func(string) bool { return true }, nil)
a2.now = func() time.Time { return now }
if ok, _ := a2.Try("alice", "c2", "art"); !ok {
t.Fatal("first should pass")
}
if ok, skip := a2.Try("alice", "c2", "art"); ok || skip != "cooldown" {
t.Fatalf("want cooldown, got ok=%v skip=%q", ok, skip)
}
}
+539
View File
@@ -0,0 +1,539 @@
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
package genui
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// PluginID 是插件身份(activation 台账与命令前缀共用)。
const PluginID = "generative-ui"
const (
// CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。
CommandPresent = PluginID + ".present"
// CommandSubmit 是 U5 已批准的 C 类页面动作(表单数据落插件 KV 台账;
// 不 exposeAsTool——按钮专用,模型不可直接调用)。
CommandSubmit = PluginID + ".submit"
// ActionHelp / ActionIntent 是 B 类首批加白(独立评审:builtin.help、system.intent)。
ActionHelp = "builtin.help"
ActionIntent = "system.intent"
)
// AllowedActionIDs 是 present 校验与 surfacesvc.AllowedActions 的开放动作集合(须同步)。
var AllowedActionIDs = map[string]struct{}{
CommandSubmit: {},
ActionHelp: {},
ActionIntent: {},
}
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。
任务工作页组织(用户在任务/项目工作区查看时):按任务阶段决定页面结构——需求阶段突出待澄清项与决策点(form/kv 优先);执行阶段突出步骤、进度与产物(steps/timeline/table 优先);验收阶段突出验证结果与缺项(kv+markdown 结论优先)。任务完成状态、步骤勾选等**事实字段只能来自对话上下文中的真实任务记录**(计划/待办/验证结果),你不能凭页面渲染改变任务状态——只做事实的组织与呈现。
可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
form{fields:[{key,label,type:text|textarea|number|boolean|select,options?(select必填),required?,placeholder?,help?,default?}]};
markdown{markdown};doc-ref{docId}(须为会话内存在的工件ID);
actions{actions:[{id,label,style?:primary|default|danger}]}(不带command);
chart-lite{chart:{kind:bar|line,labels:[],series:[]等长}};avatar{avatar:{name,sub?,tone?:ok|warn|danger|info,stack?}};
progress{progress:{label?,value:0~1}};compare{compare:{left:{title?,tone?,nodes?},right:{同left}}};
tree{tree:{nodes:[{id,label,tone?,badge?,count?,children?}]}};
table{table:{columns:[{key,label}],rows:[{id?,cells:[[key,值]与columns等长同序}]}};
steps{steps:{current:0起,steps:[{label,status?:pending|active|done|failed,tone?}]}};
tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title,time?,tone?,markdown?,ref?}]}}。
规则:每节点可带 id(整页唯一稳定)与 source[](支撑事实的来源标识);事实性内容标注来源,
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
actions 节点纪律:action 的 id 仅允许开放白名单——generative-ui.submit(C 类表单提交)、
builtin.help(B 类只读帮助)、system.intent(B 类意图匹配;button 入口只返回匹配结果、
不嵌套分发业务命令)。引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
{"title":"示例页","nodes":[
{"type":"section","id":"n1","title":"结论","children":[
{"type":"markdown","id":"n1a","source":["doc:m1"],"markdown":"结论先行。"}]},
{"type":"table","id":"n2","table":{"columns":[{"key":"k","label":"维度"},{"key":"v","label":"值"}],
"rows":[{"id":"r1","cells":[["k","报价"],["v","¥8/人/天"]]}]}},
{"type":"steps","id":"n3","steps":{"current":0,"steps":[{"label":"评审","status":"pending"}]}}]}`
func init() {
extension.RegisterCompiled(extension.InProc{ID: PluginID, Register: register})
}
func register(reg extension.Registry) error {
spec := json.RawMessage(fmt.Sprintf(`{
"id": %q,
"title": "生成页面",
"description": %q,
"paramsSchema": {
"type": "object",
"properties": {
"spec": {"type": "object", "description": "页面规格:顶层恰为 {title: string, nodes: [节点数组]}",
"properties": {"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object"}}},
"required": ["title", "nodes"]},
"surfaceId": {"type": "string", "description": "修订已有页面时传入"},
"baseRevision": {"type": "integer", "description": "修订基于的当前修订号"},
"summary": {"type": "string", "description": "一句话语义摘要(回放与索引用)"}
},
"required": ["spec"]
},
"exposeAsTool": {"risk": "low"}
}`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog))
if err := reg.AddCommandHandler(spec, present); err != nil {
return err
}
submitSpec := json.RawMessage(`{
"id": "generative-ui.submit",
"title": "提交表单",
"description": "页面动作:提交用户填写的表单数据(落插件台账;按钮触发,不经模型)",
"paramsSchema": {
"type": "object",
"properties": {
"surfaceId": {"type": "string"},
"revision": {"type": "integer"},
"requestId": {"type": "string"},
"data": {"type": "object"}
},
"required": ["surfaceId", "requestId", "data"]
}
}`)
return reg.AddCommandHandler(submitSpec, submitForm)
}
// submitForm 是 C 类动作处理器:表单数据落插件自有 KV 台账(键含 requestId
// 天然幂等;已批准范围限定 KV+任务台账,不建业务事实表)。经命令总线四入口
// 同源:审计信封与(若未来升档)高危确认卡由总线收口。
func submitForm(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
surfaceID, _ := req.Params["surfaceId"].(string)
requestID, _ := req.Params["requestId"].(string)
if surfaceID == "" || requestID == "" {
return nil, fmt.Errorf("generative-ui.submit: surfaceId and requestId required")
}
data, _ := json.Marshal(req.Params["data"])
key := "form:" + surfaceID + ":" + requestID
if err := h.KVPut(key, string(data)); err != nil {
return nil, err
}
if err := h.Audit("surface.form_submitted", surfaceID, "requestId="+requestID+" bytes="+fmt.Sprint(len(data))); err != nil {
return nil, err
}
return map[string]any{"ok": true, "stored": true, "key": key}, nil
}
// ValidateActionIDs 供 present 校验:actions 节点引用的 id 必须在已开放集合内。
func ValidateActionIDs(spec []byte) []string {
var s struct {
Nodes []core.UINode `json:"nodes"`
}
if err := json.Unmarshal(spec, &s); err != nil {
return nil
}
var errs []string
var walk func(n core.UINode, path string)
walk = func(n core.UINode, path string) {
if n.Type == core.UIActions {
for _, a := range n.Actions {
if _, ok := AllowedActionIDs[a.ID]; !ok {
errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(不在开放白名单)", path, a.ID))
}
}
}
for i, c := range n.Children {
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
}
if n.Compare != nil {
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
for i, c := range sd.Nodes {
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
}
}
}
if n.Tabs != nil {
for i, tb := range n.Tabs.Tabs {
for j, c := range tb.Nodes {
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
}
}
}
}
for i, n := range s.Nodes {
walk(n, fmt.Sprintf("$[%d]", i))
}
return errs
}
// present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。
// 能力缺失(宿主未装配)与未启用明确报错;doc-ref 经 owner 作用域工件解析做
// 真实授权(伪造引用拒绝);校验失败返回字段级清单供模型修复重提(至多一次)。
func present(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
sh, ok := h.(extension.SurfaceHost)
if !ok {
return nil, fmt.Errorf("generative-ui: surface capability not available in this host")
}
enabled, epoch, err := sh.SurfaceActivation(ctx)
if err != nil {
return nil, err
}
if !enabled {
return nil, fmt.Errorf("generative-ui: 插件未启用(owner 未开启 Surface 能力),本次提交被拒绝")
}
raw, _ := json.Marshal(normalizeSpecParam(req.Params["spec"]))
surfaceID, _ := req.Params["surfaceId"].(string)
base := 0
if v, ok := req.Params["baseRevision"].(float64); ok {
base = int(v)
}
summary, _ := req.Params["summary"].(string)
// doc-ref 真实授权:spec 里的 docId 必须是当前 owner 可解析的工件(伪造即拒)。
if errs := authorizeDocRefs(raw, h); len(errs) > 0 {
return toolError("invalid spec", errs), nil
}
// 动作白名单(U5 已批准范围):未知动作 id 拒绝并回字段级错误。
if errs := ValidateActionIDs(raw); len(errs) > 0 {
return toolError("invalid spec", errs), nil
}
snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{
SurfaceID: core.ID(surfaceID),
ConversationID: core.ID(req.ConversationID),
BaseRevision: base,
ObservedEpoch: epoch,
SemanticText: summary,
Spec: raw,
})
if err != nil {
if strings.HasPrefix(err.Error(), "surface: invalid spec:") {
details := strings.TrimSpace(strings.TrimPrefix(err.Error(), "surface: invalid spec:"))
var list []string
for _, d := range strings.Split(details, ";") {
if t := strings.TrimSpace(d); t != "" {
list = append(list, t)
}
}
return toolError("invalid spec", list), nil
}
return nil, err
}
return map[string]any{
"ok": true,
"surfaceId": string(snap.Identity.SurfaceID),
"revision": snap.Revision,
"epoch": snap.ActivationEpoch,
"summary": snap.SemanticText,
"note": "页面已保存;用户在预览区查看。修订时带 surfaceId 与 baseRevision 重提。",
}, nil
}
// toolError 是返回给模型的字段级错误(工具结果错误,非分发错误)。
type toolErrorBody struct {
OK bool `json:"ok"`
Error string `json:"error"`
Details []string `json:"details,omitempty"`
Note string `json:"note"`
}
func toolError(msg string, details []string) toolErrorBody {
return toolErrorBody{OK: false, Error: msg, Details: details,
Note: "按错误修正 spec 后可重提一次;再次失败请改用文本回答。"}
}
// normalizeSpecParam 是插件的 schema 修复策略(r1 KTD1;U3 回放实测的模型偏差
// 逐条落地):单键包装({"page":…})解包;顶层 children→nodes;kv/cells 的
// 对象形式({"k":…,"v":…}/{"key":…,"value":…})改写为二元组数组。只做结构
// 改写,不改内容语义;修复后仍过 core 校验器。
func normalizeSpecParam(v any) any {
if arr, ok := v.([]any); ok {
return map[string]any{"title": "", "nodes": arr}
}
m, ok := v.(map[string]any)
if !ok {
return v
}
if len(m) == 1 {
if inner, ok := m["page"].(map[string]any); ok {
m = inner
}
}
if _, ok := m["nodes"]; !ok {
if ch, ok := m["children"].([]any); ok {
m["nodes"] = ch
delete(m, "children")
}
}
// 页级 source(模型的常见引用写法):分发给未自带 source 的节点——保留引用
// 信息,不发明内容;节点自带 source 者不受影响。
if top, ok := m["source"].([]any); ok {
delete(m, "source")
if nodes, ok := m["nodes"].([]any); ok {
for _, n := range nodes {
if nm, ok := n.(map[string]any); ok {
if _, has := nm["source"]; !has {
cp := make([]any, len(top))
copy(cp, top)
nm["source"] = cp
}
}
}
}
}
repairChildrenShape(m)
// {"item": …} 单键包装在 children 形状修复之后剥:children 的 {"item":{node}}
// 由 repairChildrenShape 的对象映射规则兜住,先剥反而会把单子节点打散进
// 值数组;kv/cells/items/columns 等其余数组字段在这里统一解到本体。
if unwrapped, ok := repairItemWrappers(m).(map[string]any); ok {
m = unwrapped
}
repairPairs(m)
repairTableCells(m)
return m
}
// repairItemWrappers 递归剥掉 XML-RPC 风格的 {"item": X} 单键包装(2026-10-12
// dev 实测 MiniMax-M3,事件 seq 1395/2171):数组字段(kv/cells/items/columns…)
// 整字段被包一层 {"item":[…]},数组元素再被包 {"item":["k","v"]}/{"item":"v"}。
// 规范词汇无名为 item 的字段(core/ui.go 全量核对),单键 item 不承载语义,
// 安全解包;只改结构不改内容。
func repairItemWrappers(v any) any {
switch t := v.(type) {
case map[string]any:
if len(t) == 1 {
if inner, ok := t["item"]; ok {
return repairItemWrappers(inner)
}
}
out := make(map[string]any, len(t))
for k, val := range t {
out[k] = repairItemWrappers(val)
}
return out
case []any:
out := make([]any, len(t))
for i, item := range t {
out[i] = repairItemWrappers(item)
}
return out
default:
return v
}
}
// repairTableCells 表格行 cells 的裸标量数组(同场实测):cells 期望与
// columns.key 对应的 [key,value] 二元组,模型输出成与列等长的裸值数组。
// 长度一致且列键齐全时按列序配对(结构改写,不发明内容);不一致则不动,
// 交校验器给字段级错误供模型修复重提。
func repairTableCells(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
if tbl, ok := m["table"].(map[string]any); ok {
cols, colsOK := tbl["columns"].([]any)
rows, rowsOK := tbl["rows"].([]any)
if colsOK && rowsOK {
keys := make([]string, len(cols))
valid := true
for i, c := range cols {
cm, ok := c.(map[string]any)
if !ok {
valid = false
break
}
k, ok := cm["key"].(string)
if !ok || k == "" {
valid = false
break
}
keys[i] = k
}
if valid {
for _, r := range rows {
rm, ok := r.(map[string]any)
if !ok {
continue
}
cells, ok := rm["cells"].([]any)
if !ok || len(cells) != len(keys) {
continue
}
paired := make([]any, 0, len(cells))
convertible := true
for i, c := range cells {
switch cv := c.(type) {
case string:
paired = append(paired, []any{keys[i], cv})
case float64:
paired = append(paired, []any{keys[i], strconv.FormatFloat(cv, 'f', -1, 64)})
default:
convertible = false
break
}
}
if convertible {
rm["cells"] = paired
}
}
}
}
}
for _, v := range m {
if vm, ok := v.(map[string]any); ok {
repairTableCells(vm)
}
if va, ok := v.([]any); ok {
for _, item := range va {
if im, ok := item.(map[string]any); ok {
repairTableCells(im)
}
}
}
}
}
// repairChildrenShape 递归修复 children 的常见形状偏差(真实模型实测):
// children 为对象(按 key 编号的子节点映射)→ 取其值转数组;单对象 → 包一层数组。
func repairChildrenShape(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
if ch, ok := m["children"]; ok && ch != nil {
switch v := ch.(type) {
case map[string]any:
arr := make([]any, 0, len(v))
for _, val := range v {
arr = append(arr, val)
}
m["children"] = arr
case []any:
for _, c := range v {
if cm, ok := c.(map[string]any); ok {
repairChildrenShape(cm)
}
}
default:
// 单对象子节点
if cm, ok := ch.(map[string]any); ok {
m["children"] = []any{cm}
}
}
}
for _, v := range m {
if vm, ok := v.(map[string]any); ok {
repairChildrenShape(vm)
}
if va, ok := v.([]any); ok {
for _, item := range va {
if im, ok := item.(map[string]any); ok {
repairChildrenShape(im)
}
}
}
}
}
// repairPairs 递归把 "kv"/"cells" 下的对象项改写为二元组。
func repairPairs(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
for key, val := range m {
if (key == "kv" || key == "cells") && val != nil {
if arr, ok := val.([]any); ok {
fixed := make([]any, 0, len(arr))
changed := false
for _, item := range arr {
if p, ok := pairFromObject(item); ok {
fixed = append(fixed, p)
changed = true
} else {
fixed = append(fixed, item)
}
}
if changed {
m[key] = fixed
}
}
}
repairPairs(val)
}
}
// pairFromObject 从 {"k","v"}/{"key","value"}/{"name","value"} 对象提取二元组。
func pairFromObject(item any) ([2]any, bool) {
obj, ok := item.(map[string]any)
if !ok {
return [2]any{}, false
}
var k, v any
for kk, vv := range obj {
switch kk {
case "k", "key", "name":
k = vv
case "v", "value":
v = vv
default:
return [2]any{}, false
}
}
if k == nil || v == nil {
return [2]any{}, false
}
ks, ok1 := k.(string)
vs, ok2 := v.(string)
if !ok1 || !ok2 {
return [2]any{}, false
}
return [2]any{ks, vs}, true
}
// authorizeDocRefs 收集 spec 中的 doc-ref docId 并经 Host 工件面做 owner 作用域解析。
func authorizeDocRefs(raw []byte, h extension.Host) []string {
var spec struct {
Nodes []core.UINode `json:"nodes"`
}
if err := json.Unmarshal(raw, &spec); err != nil {
return []string{"spec: " + err.Error()}
}
var errs []string
var walk func(n core.UINode, path string)
walk = func(n core.UINode, path string) {
if n.Type == core.UIDocRef && n.DocID != "" {
if _, err := h.ArtifactGet(string(n.DocID)); err != nil {
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用了不存在或无权限的工件 %q", path, n.DocID))
}
}
for i, c := range n.Children {
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
}
if n.Compare != nil {
for side, sd := range map[string]core.UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
for i, c := range sd.Nodes {
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i))
}
}
}
if n.Tabs != nil {
for i, tb := range n.Tabs.Tabs {
for j, c := range tb.Nodes {
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
}
}
}
}
for i, n := range spec.Nodes {
walk(n, fmt.Sprintf("$[%d]", i))
}
return errs
}
+385
View File
@@ -0,0 +1,385 @@
package genui
// U3 红测(提案 U3 验证行):能力缺失明确拒绝;未启用拒绝;伪造 doc-ref 拒绝;
// 目录违规返回字段级清单(供模型修复重提);成功路径经 EXT-17 提交并携带观察 epoch。
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// realSvc 让 fakeHost.SurfaceSave 走真实 surfacesvc(校验/守卫行为与生产一致)。
var realSvc *surfacesvc.Service
func newRealSvc(t *testing.T) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
realSvc = surfacesvc.New(st, nil)
if _, err := realSvc.SetActivation(context.Background(), "alice", PluginID, true); err != nil {
t.Fatal(err)
}
}
type fakeHost struct {
extension.Host // 未覆盖方法仅测试未触达
enabled bool
epoch int64
saved []extension.SurfaceSaveRequest
artifacts map[string]bool
}
func (h *fakeHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
return h.enabled, h.epoch, nil
}
func (h *fakeHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
h.saved = append(h.saved, req)
return realSvc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID,
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
SemanticText: req.SemanticText, Spec: req.Spec,
})
}
func (h *fakeHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
return realSvc.Get(ctx, "alice", surfaceID)
}
func (h *fakeHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
return realSvc.List(ctx, "alice", conversationID)
}
func (h *fakeHost) ArtifactGet(id string) (core.Artifact, error) {
if h.artifacts[id] {
return core.Artifact{ID: core.ID(id)}, nil
}
return core.Artifact{}, store.ErrNotFound
}
func validSpec() map[string]any {
return map[string]any{
"title": "选型对比",
"nodes": []any{
map[string]any{"type": "section", "id": "n1", "title": "概览", "children": []any{
map[string]any{"type": "markdown", "id": "n1a", "source": []any{"doc:a"}, "markdown": "结论先行。"},
}},
map[string]any{"type": "table", "id": "n2", "table": map[string]any{
"columns": []any{map[string]any{"key": "k", "label": "维度"}},
"rows": []any{map[string]any{"cells": []any{[]any{"k", "价格"}}}},
}},
},
}
}
func reqWith(spec map[string]any, extra ...map[string]any) extension.Request {
params := map[string]any{"spec": spec}
for _, e := range extra {
for k, v := range e {
params[k] = v
}
}
return extension.Request{ID: "r1", Params: params, Owner: "alice", ConversationID: "c1"}
}
func TestPresentGuards(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
// 能力缺失(Host 未实现 SurfaceHost)→ 明确报错
if _, err := present(ctx, plainHost{}, reqWith(validSpec())); err == nil {
t.Fatal("capability-missing host should fail explicitly")
}
// 未启用 → 拒绝
h := &fakeHost{enabled: false, artifacts: map[string]bool{}}
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
t.Fatal("disabled plugin should reject submit")
}
// 伪造 doc-ref:工件不存在 → 字段级工具错误
h = &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{"type": "doc-ref", "docId": "art_ghost"}}}
res, err := present(ctx, h, reqWith(spec))
if err != nil {
t.Fatalf("doc-ref violation should be tool-error, got %v", err)
}
body, _ := json.Marshal(res)
if !strings.Contains(string(body), "art_ghost") {
t.Fatalf("field errors should name the bad docId: %s", body)
}
// 目录违规(未知字段 color)→ 字段级清单
bad := map[string]any{"title": "x", "nodes": []any{map[string]any{"type": "list", "items": []any{"a"}, "color": "red"}}}
res, err = present(ctx, h, reqWith(bad))
if err != nil {
t.Fatalf("invalid spec should be tool-error, got %v", err)
}
te, ok := res.(toolErrorBody)
if !ok || te.OK || len(te.Details) == 0 {
t.Fatalf("expected field-level details, got %+v", res)
}
}
func TestPresentSubmitCarriesEpochAndRevises(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
res, err := present(ctx, h, reqWith(validSpec()))
if err != nil {
t.Fatal(err)
}
m, _ := res.(map[string]any)
if m["ok"] != true || fmt.Sprint(m["revision"]) != "1" {
t.Fatalf("submit result: %+v", m)
}
if len(h.saved) != 1 || h.saved[0].ObservedEpoch != 1 {
t.Fatalf("submit must carry observed epoch: %+v", h.saved)
}
// 修订:带 surfaceId + baseRevision → revision 2
res2, err := present(ctx, h, reqWith(validSpec(), map[string]any{
"surfaceId": m["surfaceId"], "baseRevision": float64(1)}))
if err != nil {
t.Fatal(err)
}
m2, _ := res2.(map[string]any)
if fmt.Sprint(m2["revision"]) != "2" {
t.Fatalf("revision should advance to 2: %+v", m2)
}
// 禁用后:执行端拒绝(闸外第二道守卫)
h.enabled = false
if _, err := present(ctx, h, reqWith(validSpec())); err == nil {
t.Fatal("post-disable submit should reject")
}
}
func TestRegisterSpecShape(t *testing.T) {
var captured []json.RawMessage
reg := &fakeRegistry{captureList: &captured}
if err := register(reg); err != nil {
t.Fatal(err)
}
byID := map[string]json.RawMessage{}
for _, raw := range captured {
var id struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &id); err != nil {
t.Fatal(err)
}
byID[id.ID] = raw
}
var spec struct {
ID string `json:"id"`
Description string `json:"description"`
ExposeAsTool map[string]any `json:"exposeAsTool"`
}
if raw, ok := byID[CommandPresent]; !ok {
t.Fatalf("present command not registered: %v", captured)
} else if err := json.Unmarshal(raw, &spec); err != nil {
t.Fatal(err)
}
if spec.ID != CommandPresent || spec.ExposeAsTool == nil {
t.Fatalf("present must expose as tool: %s", byID[CommandPresent])
}
for _, need := range []string{"section{", "compare{", "steps{", "≤200 节点", "generative-ui.submit", "builtin.help", "system.intent"} {
if !strings.Contains(spec.Description, need) {
t.Fatalf("catalog incomplete: missing %q", need)
}
}
var submit struct {
ExposeAsTool map[string]any `json:"exposeAsTool"`
}
if raw, ok := byID[CommandSubmit]; !ok {
t.Fatalf("submit command not registered: %v", captured)
} else {
_ = json.Unmarshal(raw, &submit)
if submit.ExposeAsTool != nil {
t.Fatal("submit must NOT expose as tool (button-only; model cannot call)")
}
}
}
type plainHost struct{ extension.Host }
type fakeRegistry struct{ captureList *[]json.RawMessage }
func (r *fakeRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
*r.captureList = append(*r.captureList, spec)
return nil
}
func (r *fakeRegistry) AddSkill(name, markdown string) error { return nil }
func (r *fakeRegistry) AddCommand(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return nil }
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
return nil
}
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
newRealSvc(t)
ctx := context.Background()
h := &fakeHost{enabled: true, epoch: 1, artifacts: map[string]bool{}}
spec := map[string]any{"title": "t", "nodes": []any{map[string]any{
"type": "actions", "id": "a1",
"actions": []any{
map[string]any{"id": "shell.exec", "label": "危险"},
map[string]any{"id": "generative-ui.submit", "label": "提交"},
}}}}
res, err := present(ctx, h, reqWith(spec))
if err != nil {
t.Fatalf("should be tool error, got %v", err)
}
te, ok := res.(toolErrorBody)
if !ok || te.OK || len(te.Details) == 0 {
t.Fatalf("expected field errors, got %+v", res)
}
if !strings.Contains(strings.Join(te.Details, ";"), "shell.exec") {
t.Fatalf("errors should name the unknown action: %+v", te)
}
}
func TestValidateActionIDsAllowsBClassHelpAndIntent(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"title": "t", "nodes": []any{map[string]any{
"type": "actions", "id": "a1",
"actions": []any{
map[string]any{"id": ActionHelp, "label": "帮助"},
map[string]any{"id": ActionIntent, "label": "意图"},
map[string]any{"id": CommandSubmit, "label": "提交"},
}}}})
if errs := ValidateActionIDs(raw); len(errs) != 0 {
t.Fatalf("B-class help/intent + submit should pass, got %v", errs)
}
}
func TestSubmitFormStoresLedger(t *testing.T) {
// C 类:表单数据落插件 KV 台账(键含 requestId 天然幂等)。
var kv map[string]string = map[string]string{}
h := &ledgerHost{kv: kv}
res, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
"surfaceId": "surf_x", "requestId": "req-1", "revision": float64(1),
"data": map[string]any{"years": "5"},
}})
if err != nil {
t.Fatal(err)
}
m, _ := res.(map[string]any)
if m["ok"] != true || m["stored"] != true {
t.Fatalf("submit result: %+v", m)
}
if v, ok := kv["form:surf_x:req-1"]; !ok || !strings.Contains(v, `"years":"5"`) {
t.Fatalf("ledger missing data: %v", kv)
}
// 幂等重放同键覆盖同值
if _, err := submitForm(context.Background(), h, extension.Request{Params: map[string]any{
"surfaceId": "surf_x", "requestId": "req-1", "data": map[string]any{"years": "5"},
}}); err != nil {
t.Fatal(err)
}
}
type ledgerHost struct {
extension.Host
kv map[string]string
}
func (h *ledgerHost) KVPut(key, value string) error { h.kv[key] = value; return nil }
func (h *ledgerHost) Audit(action, target, evidence string) error { return nil }
func TestNormalizeChildrenShape(t *testing.T) {
// 真实偏差(2026-10-11 dev 实测):模型把 children 写成按 key 编号的对象映射。
in := map[string]any{
"type": "section", "id": "n1", "title": "结论",
"children": map[string]any{
"item": map[string]any{"type": "markdown", "id": "m1", "markdown": "结论先行"},
},
}
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
nodes := out.(map[string]any)["nodes"].([]any)
ch := nodes[0].(map[string]any)["children"].([]any)
if len(ch) != 1 || ch[0].(map[string]any)["id"] != "m1" {
t.Fatalf("children should be repaired to array, got %v", ch)
}
}
// 真实偏差(2026-10-12 dev 实测,事件 seq 1395/2171):MiniMax-M3 对数组字段输出
// XML-RPC 风格 {"item": …} 包装——kv/cells/items 整字段被包一层 {"item":[…]},
// 数组元素再被包 {"item":["k","v"]}/{"item":"v"};表格行 cells 另有与列等长的
// 裸标量数组形态。修复层须解包并按列 key 配对,present 才能落库。
func TestPresentRepairsItemWrappers(t *testing.T) {
newRealSvc(t)
h := &fakeHost{enabled: true, epoch: 1}
spec := map[string]any{
"title": "本月支出结构分析(示例数据演示)",
"nodes": []any{
map[string]any{"type": "markdown", "id": "disclaimer", "markdown": "示例数据,非真实账单。"},
map[string]any{"type": "kv", "id": "kv1", "kv": map[string]any{"item": []any{
map[string]any{"item": []any{"月份", "2026-10"}},
map[string]any{"item": []any{"总支出", "¥10,000"}},
}}},
map[string]any{"type": "list", "id": "l1", "items": map[string]any{"item": []any{
map[string]any{"item": "餐饮 35%"},
map[string]any{"item": "住房 25%"},
}}},
map[string]any{"type": "table", "id": "t1", "table": map[string]any{
"columns": []any{
map[string]any{"key": "cat", "label": "分类"},
map[string]any{"key": "amt", "label": "金额"},
map[string]any{"key": "pct", "label": "占比"},
},
"rows": []any{
map[string]any{"cells": map[string]any{"item": []any{
map[string]any{"item": "餐饮"}, map[string]any{"item": "¥3,500"}, map[string]any{"item": "35%"},
}}},
},
}},
},
}
res, err := present(context.Background(), h, reqWith(spec))
if err != nil {
t.Fatalf("present: %v", err)
}
if body, ok := res.(toolErrorBody); ok {
t.Fatalf("present should repair item wrappers, got %s %v", body.Error, body.Details)
}
if len(h.saved) != 1 {
t.Fatalf("expected one saved surface, got %d", len(h.saved))
}
var saved core.SurfaceSpec
if err := json.Unmarshal(h.saved[0].Spec, &saved); err != nil {
t.Fatalf("saved spec should unmarshal: %v", err)
}
if saved.Nodes[1].KV[0] != [2]string{"月份", "2026-10"} || saved.Nodes[1].KV[1][1] != "¥10,000" {
t.Fatalf("kv pairs should be unwrapped, got %v", saved.Nodes[1].KV)
}
if len(saved.Nodes[2].Items) != 2 || saved.Nodes[2].Items[0] != "餐饮 35%" {
t.Fatalf("list items should be unwrapped, got %v", saved.Nodes[2].Items)
}
cells := saved.Nodes[3].Table.Rows[0].Cells
if len(cells) != 3 || cells[0] != [2]string{"cat", "餐饮"} || cells[2][1] != "35%" {
t.Fatalf("table cells should pair with column keys, got %v", cells)
}
}
// 长度不匹配的裸 cells 不做发明性配对:保留原样交校验器给字段级错误。
func TestRepairTableCellsLeavesMismatchedShapes(t *testing.T) {
in := map[string]any{"type": "table", "id": "t1", "table": map[string]any{
"columns": []any{map[string]any{"key": "a"}, map[string]any{"key": "b"}},
"rows": []any{map[string]any{"cells": []any{"x", "y", "z"}}},
}}
out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}})
rows := out.(map[string]any)["nodes"].([]any)[0].(map[string]any)["table"].(map[string]any)["rows"].([]any)
got := rows[0].(map[string]any)["cells"].([]any)
if len(got) != 3 || got[0] != "x" {
t.Fatalf("mismatched scalars should stay untouched, got %v", got)
}
}
+251
View File
@@ -0,0 +1,251 @@
package genui
// U3 完成条件回放(真实模型,env 门控):任务→规格→保存→(语义投影回灌)→
// 继续修改→新修订。跑生产代码路径:present 处理器 + surfacesvc + store + ctxasm
// 投影(AssembleOptions.Surfaces)。模型 MiniMax-M3(2026-10-11 裁定)。
//
// 启用:GENUI_MINIMAX_API_KEY=<key> go test ./internal/genui/ -run TestRealModelSurfaceLoop -v -count=1
// 未设 env 时跳过(CI 无凭据;本地收据经 make evidence 记录)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/store"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
const materials = `材料:
【doc:m1】甲方案:报价 ¥8/人/天;私有化可选;近半年发布 6 次,平均修复 2 天。
【doc:m2】乙方案:报价 ¥12/人/天;仅 SaaS;万行表格场景无官方基准;升级断层(近两季仅小版本)。
【doc:m3】尽调备忘:合规缺口(高)——数据出境条款未覆盖;培训成本(低)——术语不一致。`
type chatResp struct {
Choices []struct {
Message struct {
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
func callModelWithRepair(t *testing.T, ctx context.Context, system, user string, try func(map[string]any) (any, error)) (map[string]any, any, chatResp) {
t.Helper()
var usage chatResp
params, r := callModel(t, ctx, system, user)
usage.Usage.PromptTokens += r.Usage.PromptTokens
usage.Usage.CompletionTokens += r.Usage.CompletionTokens
res, err := try(params)
if err == nil {
if te, ok := res.(toolErrorBody); !ok || te.OK {
return params, res, usage
} else {
res = te
}
} else if res == nil {
t.Fatalf("dispatch error: %v", err)
}
// 修复轮:字段级错误回传,模型重提一次(KTD2)。
fixUser := user + "\n\n上一次提交被拒绝:" + fmt.Sprint(res) + "\n请修正后重新调用工具。"
params2, r2b := callModel(t, ctx, system, fixUser)
usage.Usage.PromptTokens += r2b.Usage.PromptTokens
usage.Usage.CompletionTokens += r2b.Usage.CompletionTokens
res2, err2 := try(params2)
if err2 != nil {
t.Fatalf("repair dispatch error: %v", err2)
}
if te, ok := res2.(toolErrorBody); ok && !te.OK {
t.Fatalf("repair still invalid: %+v", te)
}
return params2, res2, usage
}
func callModel(t *testing.T, ctx context.Context, system, user string) (map[string]any, chatResp) {
t.Helper()
key := os.Getenv("GENUI_MINIMAX_API_KEY")
tool := map[string]any{"type": "function", "function": map[string]any{
"name": CommandPresent, "description": Catalog, "parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"spec": map[string]any{"type": "object"},
"surfaceId": map[string]any{"type": "string"},
"baseRevision": map[string]any{"type": "integer"},
"summary": map[string]any{"type": "string"},
},
"required": []string{"spec"},
}}}
body, _ := json.Marshal(map[string]any{
"model": "MiniMax-M3", "temperature": 0.3, "max_tokens": 14000,
"tools": []any{tool}, "tool_choice": map[string]any{"type": "function", "function": map[string]any{"name": CommandPresent}},
"messages": []any{
map[string]any{"role": "system", "content": system},
map[string]any{"role": "user", "content": user},
}})
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.minimaxi.com/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
var out chatResp
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("model call: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Choices) == 0 || len(out.Choices[0].Message.ToolCalls) == 0 {
t.Fatalf("no tool call in response")
}
var params map[string]any
if err := json.Unmarshal([]byte(out.Choices[0].Message.ToolCalls[0].Function.Arguments), &params); err != nil {
t.Fatalf("tool args: %v", err)
}
return params, out
}
func TestRealModelSurfaceLoop(t *testing.T) {
if os.Getenv("GENUI_MINIMAX_API_KEY") == "" {
t.Skip("GENUI_MINIMAX_API_KEY not set; real-model replay runs locally with recorded receipts")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "replay.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
svc := surfacesvc.New(st, nil)
if _, err := svc.SetActivation(ctx, "alice", PluginID, true); err != nil {
t.Fatal(err)
}
h := &realHost{svc: svc, artifacts: map[string]bool{}}
// 回合 1:任务→规格→保存
sys1 := "你是本产品中的助手。用户需要一页比较视图时,用 generative-ui.present 提交页面。"
try1 := func(p map[string]any) (any, error) {
return present(ctx, h, extension.Request{ID: "t1", Params: p, Owner: "alice", ConversationID: "c1"})
}
_, res1, r1 := callModelWithRepair(t, ctx, sys1, materials+"\n\n目标:给决策会准备风险分析页(按严重度组织,标注来源)。", try1)
m1, _ := res1.(map[string]any)
if m1["ok"] != true {
t.Fatalf("turn1 not accepted: %+v", res1)
}
snap1, err := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
if err != nil {
t.Fatal(err)
}
t.Logf("turn1: surface=%s revision=%d nodes=%d tokens=%d/%d",
m1["surfaceId"], snap1.Revision, countNodes(snap1), r1.Usage.PromptTokens, r1.Usage.CompletionTokens)
// 回合 2:语义投影回灌 + 修订指令 → 新修订
msgs2, _ := ctxasm.AssembleInput(core.AgentProfile{}, nil, nil,
core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "改为实施顺序"}}},
nil, nil, nil, nil, nil, nil, ctxasm.AssembleOptions{Surfaces: []core.SurfaceSnapshot{snap1}})
var sysParts []string
for _, m := range msgs2 {
if string(m.Role) == "system" {
sysParts = append(sysParts, m.Content)
}
}
sys2 := strings.Join(sysParts, "\n")
try2 := func(p map[string]any) (any, error) {
p["surfaceId"] = m1["surfaceId"]
p["baseRevision"] = float64(snap1.Revision)
return present(ctx, h, extension.Request{ID: "t2", Params: p, Owner: "alice", ConversationID: "c1"})
}
_, res2, r2 := callModelWithRepair(t, ctx, sys1+"\n"+sys2, materials+"\n\n目标:改为实施顺序(阶段与依赖),修订已有页面。", try2)
m2, _ := res2.(map[string]any)
if fmt.Sprint(m2["revision"]) != fmt.Sprint(snap1.Revision+1) {
t.Fatalf("turn2 should revise to %d, got %+v", snap1.Revision+1, res2)
}
snap2, _ := svc.Get(ctx, "alice", core.ID(fmt.Sprint(m1["surfaceId"])))
types2 := dominantTypes(snap2)
same := len(types2) == len(snap1.Spec.Nodes)
if same {
for i := range types2 {
if types2[i] != string(snap1.Spec.Nodes[i].Type) {
same = false
break
}
}
}
if len(snap2.Spec.Nodes) == 0 || (snap2.Spec.Title == snap1.Spec.Title && same) {
t.Fatalf("turn2 structure did not change: %v", types2)
}
t.Logf("turn2: revision=%d title=%q types=%v tokens=%d/%d",
snap2.Revision, snap2.Spec.Title, types2, r2.Usage.PromptTokens, r2.Usage.CompletionTokens)
totalIn := r1.Usage.PromptTokens + r2.Usage.PromptTokens
totalOut := r1.Usage.CompletionTokens + r2.Usage.CompletionTokens
cost := float64(totalIn)/1e6*0.3*7.2 + float64(totalOut)/1e6*1.2*7.2
t.Logf("cost: in=%d out=%d ≈¥%.3f (M3 $0.3/$1.2 per M, 7.2)", totalIn, totalOut, cost)
}
type realHost struct {
extension.Host
svc *surfacesvc.Service
artifacts map[string]bool
}
func (h *realHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
row, err := h.svc.Activation(ctx, "alice", PluginID)
return row.Enabled, row.Epoch, err
}
func (h *realHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
return h.svc.Submit(ctx, "alice", PluginID, surfacesvc.SubmitRequest{
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, BaseRevision: req.BaseRevision,
ObservedEpoch: req.ObservedEpoch, SemanticText: req.SemanticText, Spec: req.Spec})
}
func (h *realHost) SurfaceGet(ctx context.Context, id core.ID) (core.SurfaceSnapshot, error) {
return h.svc.Get(ctx, "alice", id)
}
func (h *realHost) SurfaceList(ctx context.Context, cid core.ID) ([]core.SurfaceMeta, error) {
return h.svc.List(ctx, "alice", cid)
}
func (h *realHost) ArtifactGet(id string) (core.Artifact, error) {
if h.artifacts[id] {
return core.Artifact{ID: core.ID(id)}, nil
}
return core.Artifact{}, store.ErrNotFound
}
func countNodes(s core.SurfaceSnapshot) int {
var n int
var walk func(u core.UINode)
walk = func(u core.UINode) {
n++
for _, c := range u.Children {
walk(c)
}
}
for _, x := range s.Spec.Nodes {
walk(x)
}
return n
}
func dominantTypes(s core.SurfaceSnapshot) []string {
var out []string
for _, n := range s.Spec.Nodes {
out = append(out, string(n.Type))
}
return out
}
+130 -17
View File
@@ -1,8 +1,14 @@
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3):
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3 / 协作存储 U6 切片一):
// 落盘事务提交后 NOTIFY agent_events '<conv>|<lastSeq>';每实例一个 LISTEN 连接,
// 收到通知按 seq 从库取回事件并转发本地 bus(SSE 以 seq 去重,双投无害)。
// 收到通知按会话游标从库分页追赶转发本地 bus(SSE 以 seq 去重,重投无害)。
// 多实例部署契约:run 在其归属实例内存中执行,会话流量需按会话亲和路由
// (网关/客户端择点);本包只解决“其他实例的订阅者”可见性。
//
// U6(KTD6)修复的两个既有缺口:
// - 批内遗漏:通知只带末 seq,旧实现 EventAt 只取末条——批内中间事件对
// 实时订阅者不可见。现按会话游标 EventsAfter 分页追赶,末 seq 即水位;
// - 断线假重连:旧实现对原连接继续 WaitForNotification。现真正重建
// 连接→LISTEN→按全部已知会话追赶,另有周期补查覆盖通知丢失。
package pgbus
import (
@@ -10,6 +16,7 @@ import (
"log"
"strconv"
"strings"
"sync"
"time"
"agent-system-backend/internal/bus"
@@ -21,6 +28,12 @@ import (
const channel = "agent_events"
// catchUpPage 追赶分页大小(验收含 1000+ 事件分页可见)。
const catchUpPage = 500
// rescueEvery 周期补查间隔:覆盖 NOTIFY 丢失与监听窗口前后竞争(KTD6)。
const rescueEvery = 2 * time.Second
// Notifier 落盘后通知(任意连接池连接可用)。
type Notifier struct{ st *store.Store }
@@ -39,53 +52,153 @@ func (n *Notifier) Notify(conversationID string, evs []core.StreamEvent) {
}
}
// Listener 每实例一个:LISTEN → 按 seq 取回 → 本地 bus 转发。
// Listener 每实例一个:LISTEN → 按会话游标追赶 → 本地 bus 转发。
type Listener struct {
conn *pgx.Conn
dsn string
st *store.Store
bus *bus.Bus
done chan struct{}
mu sync.Mutex
cursor map[string]int64 // conv → 已转发水位(重启清零:SSE 侧重放兜底)
}
// Start 建立监听连接并开始转发循环;返回后即可用,随 ctx 取消停机。
func Start(ctx context.Context, dsn string, st *store.Store, b *bus.Bus) (*Listener, error) {
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
l := &Listener{dsn: dsn, st: st, bus: b, done: make(chan struct{}), cursor: map[string]int64{}}
if err := l.connect(ctx); err != nil {
return nil, err
}
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
conn.Close(ctx)
return nil, err
}
l := &Listener{conn: conn, st: st, bus: b, done: make(chan struct{})}
go l.loop(ctx)
return l, nil
}
func (l *Listener) connect(ctx context.Context) error {
conn, err := pgx.Connect(ctx, l.dsn)
if err != nil {
return err
}
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
conn.Close(ctx)
return err
}
l.conn = conn
return nil
}
func (l *Listener) loop(ctx context.Context) {
defer close(l.done)
// 周期补查独立 goroutine:通知丢失场景恰恰没有通知可触发——不能只在
// 收到通知后顺带补查(KTD6)。
go l.rescueLoop(ctx)
for {
n, err := l.conn.WaitForNotification(ctx)
if err != nil {
if ctx.Err() != nil {
return // 停机
}
log.Printf("[pgbus] wait: %v(1s 后重连)", err)
time.Sleep(time.Second)
// 真重连(U6):旧实现对原连接继续 Wait——断线后永不恢复。
// 退避重建 → LISTEN → 按已知会话追赶(补断线窗口内落盘的事件)。
log.Printf("[pgbus] wait: %v(重连中)", err)
l.reconnect(ctx)
continue
}
conv, seq, ok := parsePayload(n.Payload)
if !ok {
continue
}
ev, err := l.st.EventAt(conv, seq)
if err != nil {
continue // 事件不可见(极早期窗口)——SSE 重放兜底
}
l.bus.Publish(ev)
l.catchUp(conv, seq)
}
}
// rescueLoop 周期补查心跳:随 ctx 或主循环退出(done)停止。
func (l *Listener) rescueLoop(ctx context.Context) {
t := time.NewTicker(rescueEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-l.done:
return
case <-t.C:
l.rescueKnown()
}
}
}
// reconnect 重建监听连接并立即追赶全部已知会话(有界退避重试)。
func (l *Listener) reconnect(ctx context.Context) {
_ = l.conn.Close(context.Background())
for attempt := 0; attempt < 3; attempt++ {
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := l.connect(cctx)
cancel()
if err == nil {
l.rescueKnown()
return
}
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(attempt+1) * time.Second):
}
}
}
// rescueKnown 周期补查:不经通知,按各会话游标继续分页追赶(覆盖 NOTIFY
// 丢失、监听生效窗口前后竞争——KTD6「只有落库事件才是重放依据」)。
// 调用方:重连成功后与 loop 的 rescue 心跳(消息稀疏时由 ticker 兜底)。
func (l *Listener) rescueKnown() {
l.mu.Lock()
convs := make([]string, 0, len(l.cursor))
for c := range l.cursor {
convs = append(convs, c)
}
l.mu.Unlock()
for _, conv := range convs {
l.catchUp(conv, 1<<62)
}
}
// catchUp 从会话游标之后分页取回并转发,直到取空或越过水位 toSeq。
// 首见会话从 0 开始(SSE 端按 seq 去重,重投无害;这保证批内中间事件可见)。
func (l *Listener) catchUp(conv string, toSeq int64) {
l.mu.Lock()
since := l.cursor[conv]
l.mu.Unlock()
for {
evs, err := l.st.EventsAfter(conv, since, catchUpPage)
if err != nil {
log.Printf("[pgbus] catchup %s@%d: %v", conv, since, err)
return
}
stop := false
for _, ev := range evs {
if ev.Seq > toSeq {
stop = true
break
}
l.bus.Publish(ev)
since = ev.Seq
}
l.setCursor(conv, since)
if stop || len(evs) < catchUpPage {
return
}
}
}
func (l *Listener) setCursor(conv string, seq int64) {
l.mu.Lock()
if seq > l.cursor[conv] {
l.cursor[conv] = seq
}
l.mu.Unlock()
}
// parsePayload 拆 '<conv>|<seq>'(会话 ID 不含 '|')。
func parsePayload(p string) (string, int64, bool) {
i := strings.LastIndex(p, "|")
if i < 0 {
+161 -1
View File
@@ -29,6 +29,12 @@ func pgTestStore(t *testing.T) (*store.Store, string) {
if err != nil {
t.Fatal(err)
}
// pg_trgm 预装 public(幂等),理由见 store 包 openTestStore 注释:
// 避免扩展落进用例 schema 后随 CASCADE 删除造成的并行竞态。
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("preinstall pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatal(err)
@@ -37,7 +43,7 @@ func pgTestStore(t *testing.T) (*store.Store, string) {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
full := dsn + "&search_path=" + schema
full := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
st, err := store.OpenPostgres(full, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
@@ -80,3 +86,157 @@ func TestNotifyListenerFanout(t *testing.T) {
t.Fatal("no fanout within 3s")
}
}
// ---- U6 切片一(KTD6):批内全可见、分页追赶、断线重连、通知丢失补查 ----
// drainUntil 从订阅通道收集事件直到谓词为真或超时;返回收到的全部事件。
func drainUntil(ch <-chan core.StreamEvent, done func([]core.StreamEvent) bool, timeout time.Duration) []core.StreamEvent {
var got []core.StreamEvent
deadline := time.After(timeout)
for {
select {
case e := <-ch:
got = append(got, e)
if done(got) {
return got
}
case <-deadline:
return got
}
}
}
// 一批 64 条事件(单条 NOTIFY 只带末 seq)对实时订阅者全部可见——
// 旧实现 EventAt 只取末条,批内 1..63 全部遗漏。
func TestBatchEventsAllVisible(t *testing.T) {
st, dsn := pgTestStore(t)
b2 := bus.New()
listener, err := Start(context.Background(), dsn, st, b2)
if err != nil {
t.Fatal(err)
}
defer listener.Close(context.Background())
ch, stop := b2.Subscribe("conv_batch")
defer stop()
notifier := NewNotifier(st)
evs := make([]core.StreamEvent, 64)
for i := range evs {
evs[i] = core.StreamEvent{ConversationID: "conv_batch", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}
}
if _, err := st.AppendEvents("conv_batch", evs); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_batch", evs)
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 64 }, 5*time.Second)
if len(got) != 64 {
t.Fatalf("批 64 条应全部可见,收到 %d(旧实现只末条可见)", len(got))
}
// 序号连续(首见会话从 0 追赶,SSE 去重语义下的完整性)。
for i, e := range got {
if e.Seq != int64(i+1) {
t.Fatalf("序号不连续:got[%d].Seq=%d", i, e.Seq)
}
}
}
// 1200 条(跨 500/页 的三页)分页追赶全部可见。
func TestPagedCatchUpBeyondOnePage(t *testing.T) {
st, dsn := pgTestStore(t)
b2 := bus.New()
listener, err := Start(context.Background(), dsn, st, b2)
if err != nil {
t.Fatal(err)
}
defer listener.Close(context.Background())
ch, stop := b2.Subscribe("conv_page")
defer stop()
notifier := NewNotifier(st)
const total = 1200
evs := make([]core.StreamEvent, 0, total)
for i := 0; i < total; i++ {
evs = append(evs, core.StreamEvent{ConversationID: "conv_page", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)})
}
if _, err := st.AppendEvents("conv_page", evs); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_page", evs)
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= total }, 15*time.Second)
if len(got) != total {
t.Fatalf("1200 条应分页全部可见,收到 %d", len(got))
}
}
// 断线重连:底层连接被杀后,新落盘+通知的事件仍可达(真重连路径)。
func TestListenerReconnectsAfterConnLoss(t *testing.T) {
st, dsn := pgTestStore(t)
b2 := bus.New()
listener, err := Start(context.Background(), dsn, st, b2)
if err != nil {
t.Fatal(err)
}
defer listener.Close(context.Background())
ch, stop := b2.Subscribe("conv_re")
defer stop()
notifier := NewNotifier(st)
first := []core.StreamEvent{{ConversationID: "conv_re", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
if _, err := st.AppendEvents("conv_re", first); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_re", first)
drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*time.Second)
// 杀掉监听连接(模拟网络断开;旧实现会对此连接永远 Wait 下去)。
if err := listener.conn.Close(context.Background()); err != nil {
t.Logf("close conn: %v", err)
}
// 断线窗口内落盘一批(重连后应经追赶补上)。
during := []core.StreamEvent{{ConversationID: "conv_re", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
if _, err := st.AppendEvents("conv_re", during); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_re", during)
// 第二段 drain 从零计数:断线窗口内的一条事件到达即证明重连+追赶生效。
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 10*time.Second)
if len(got) < 1 {
t.Fatalf("重连后应补见断线窗口内事件:got=%d", len(got))
}
}
// 通知丢失补查:事件落盘但**不**发 NOTIFY,周期补查应在 rescueEvery 内送达。
func TestRescueCoversLostNotifications(t *testing.T) {
st, dsn := pgTestStore(t)
b2 := bus.New()
listener, err := Start(context.Background(), dsn, st, b2)
if err != nil {
t.Fatal(err)
}
defer listener.Close(context.Background())
ch, stop := b2.Subscribe("conv_lost")
defer stop()
// 先用一次正常通知建立该会话的游标(补查只覆盖已知会话)。
notifier := NewNotifier(st)
seed := []core.StreamEvent{{ConversationID: "conv_lost", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
if _, err := st.AppendEvents("conv_lost", seed); err != nil {
t.Fatal(err)
}
notifier.Notify("conv_lost", seed)
drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*time.Second)
// 只落盘、不通知(模拟 NOTIFY 丢失)。
lost := []core.StreamEvent{{ConversationID: "conv_lost", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{}`)}}
if _, err := st.AppendEvents("conv_lost", lost); err != nil {
t.Fatal(err)
}
got := drainUntil(ch, func(g []core.StreamEvent) bool { return len(g) >= 1 }, 3*rescueEvery+2*time.Second)
if len(got) < 1 {
t.Fatalf("周期补查应送达无通知事件:got=%d", len(got))
}
}
+1 -1
View File
@@ -2,8 +2,8 @@ package plugins
import (
"os"
"strings"
"path/filepath"
"strings"
"testing"
"time"
@@ -1,7 +1,7 @@
---
id: agent.sub.explore
layer: agent
version: 1
version: 2
title: 探索子代理(只读)
description: 只读检索专家——定位文件/内容/事实,不做任何修改
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
@@ -9,6 +9,7 @@ model: lightweight
---
你是只读探索子代理,职责是高效且彻底地定位目标信息。
- 汇报发现时给出定位(文件/页/段落);数字必须来自材料原文并注明出处,没有出处的不要写。
- 严格只读:不写入、不修改、不删除任何文件或数据;只执行检索与读取类操作。
- 检索策略:从任务给出的线索出发,多角度查询(不同关键词、不同路径);一次不中就换查询,不重复同一查询。
- 返回结论时给出定位(文件/路径/段落/链接),让派生方能直接复核。
@@ -1,14 +1,16 @@
---
id: agent.sub.general
layer: agent
version: 1
version: 3
title: 通用子代理
description: 独立完成子任务并返回结构化结果(默认子代理提示词)
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, generative-ui.present
model: lightweight
---
你是被派生的子代理,负责独立完成一个子任务。
- 结论中的关键数字与事实附来源定位(材料/文件与页/段落);没有出处的数字不要写,无法回溯时说明来源缺失。
- 把任务完整做完:既不过度修饰,也不半途而废;遇到障碍先自行尝试解决,确实无法解决时在结果中说明卡点与已尝试的手段。
- 只使用与任务相关的信息,不臆测派生方未提供的上下文。
- 完成时返回结构化结果:做了什么、关键发现/结论、产出物位置(如有)、遗留问题(如有)。分点列出,关键内容不省略;摘要长度服从内容,不为简短而丢信息。
- 页面化产物(分析页/对比页等)用 `generative-ui.present` 提交;工具不可用或提交被拒时改用 `write_artifact` 落盘并在结果中说明。
@@ -1,7 +1,7 @@
---
id: agent.sub.research
layer: agent
version: 1
version: 3
title: 调研子代理
description: 围绕问题多源调研,输出带依据的结论
tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir
@@ -9,6 +9,7 @@ model: lightweight
---
你是调研子代理,围绕给定问题收集信息并给出结论。
- 结论里的关键数字与事实必须附原文引用(材料/文件与页/段落定位,重要处引原句);没有出处的数字不要写,无法回溯时明确说明「来源缺失」而不是估一个。检索/读取结果带【src_N|工具】标记时,引用同时给出该标记编号。
- 先把问题拆成可回答的子问题,再逐一检索;优先项目内知识库与已给材料,其次按任务指定的外部来源。
- 每条结论标注依据来源;多来源冲突时不和稀泥,列出各方说法与差异点,说明你采信哪个及理由。
- 覆盖正反两面:支持与不支持该结论的证据都要呈现。
@@ -1,7 +1,7 @@
---
id: base.deliverables
layer: base
version: 5
version: 7
title: 交付物工作流
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
---
@@ -11,11 +11,13 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
1. **定样式**,按下面的优先级只取第一个命中项:本轮用户的明确指令(例如改成公文、换成品牌色)→ 本轮上传的模板(.docx/.dotx/.pptx/.potx/.xlsx/.xltx)→ 本轮要求仿制的参考文档(`from housestyle.extract import extract`,抽取字体、字号、色板、页边距,得到自定义主题)→ 本会话已经确定的样式 → 设置中的默认模板 → 设置中的 deliverables.style(modern / formal / minimal)和强调色。未指定时用环境段给出的默认样式。
2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。
3. **生成**:`import housestyle`,用 docx_kit / pptx_kit / xlsx_kit 的构件写文件。文件落在会话工作区。模板文件和参考原件在 `inputs/`(环境段列出相对路径)。
4. **自检**:`python -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `housestyle.summarize` 回读标题、表格和页数,核对与规划一致。
3. **生成(Word 交付物优先走源码化)**:先写 Markdown 源文件(`write_file 方案.md`,约定见 `python3 -m housestyle.build` 的 docstring:`#`标题/`##`-`####` 三级、`-`/`1.` 列表、`| |` 表格、`>` 引用、`!!! 标签 文本` 强调块、首行 `<!-- housestyle: theme=modern -->` 可选主题),再 `python3 -m housestyle.build 方案.md` 确定性渲染成 docx——build 自动跑 lint 并回读结构(写入即诊断),有 error 就改 .md 源的对应段落再重建,不要整段重写。修改已有交付物 = `edit_file` 改 .md 源 + 重新 build。Excel/PPT/图片等其他类型仍用 `run_python` + 对应 kit。文件都在会话工作区;模板和参考原件在 `inputs/`(环境段列出相对路径)。
4. **自检**:`python3 -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `python3 -m housestyle.outline_cli <文件>` 回读标题、表格和页数,核对与规划一致。
5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
检索与读取的工具结果末尾带【src_N|工具名】来源标记:成文时数字与关键事实的出处就是这些带标记的结果,逐项核对;交付物里的每一个百分比、日期和大额数字,都必须能在本轮上传材料或你自己的工具/计算输出里找到出处;找不到出处的,要么删除,要么用「目标 / 预计 / 假设」等词在文中显式标注为规划量(如「目标 85%」「预计三个月」)。终轮会对无出处且未标注的数字拦截两轮;仍无法回溯又拒绝标注的,会被系统如实记为未核实。
没有调用工具、没有登记工件时,禁止说「已生成 / 已保存 / 已导出」。回复里不要写工件 ID(art_…)。工具只能通过函数调用触发,不要在回复里书写 `[调用工具 …]`、`[工具结果 …]`、`[产出工件 …]` 这类文字。
交付物族(所有任务共用,不限于会议):
+17
View File
@@ -0,0 +1,17 @@
---
id: base.method
layer: base
version: 1
title: 工作方法
description: 把任务做完的方法论:计划、换路、查 API、坚持到完成、回到材料(常驻基座)
---
按以下方法工作:
- **先计划再动手**:预计三步以上、或要产出文件的任务,开始前用 update_plan 列出步骤;每完成一步就更新状态,同一时间只有一步进行中。计划是给自己看的进度账,不是给用户看的排场。
- **一个方法失败两次就换路**:同样的调用连续失败两次,停下来想第三条路(换工具、换参数结构、换实现方式),不要原样重试第三次。
- **写脚本前先查 API**:调用不熟悉的库/模块前,先用 grep 查它的函数签名(`inspect.signature`/`dir`)或读 SKILL.md 的示例,确认参数形态后再写完整脚本。猜 API 是失败的最大来源。
- **编辑粒度**:修改文件时先想清楚要改哪几处,用 multi_edit 或大块 edit 一次改完,不要一行一个 edit_file 连发十几次;改完立即验证(build/运行),验证通过再继续下一处。
- **坚持到完成**:任务没做完不要停。收尾前检查最后一段:如果还是计划、承诺或"接下来我将",就先把承诺的事做掉。只有用户输入才能解决的阻塞才允许停下来问。
- **重任务走快慢双速**:预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理、成套材料整理),先用当前回复给用户即时反馈——材料的相关性判断、概要或执行计划——再用 delegate_task 的 background=true 把深度工作放到后台执行;后台进度与结果会进收件箱卡片,完成后系统自动汇报。简单任务仍同步做完,不要为快而快把小事也丢后台。
- **结论回到材料**:写进交付物的数字、百分比、日期、人名必须能在本轮材料(附件/工具输出/会议原文)里找到出处;找不到的改成不写,或明确标注"推测"。材料里的日期基准与你理解的不一致时,以材料为准或向用户确认。
- **终稿如实**:最后的回复先给结论,再列交付物与要点;没做完、没验证的部分如实说明,不用模糊措辞掩盖。
+1 -1
View File
@@ -7,7 +7,7 @@ description: 何时用工具、如何并行调用、如何处理工具结果
---
使用工具时遵循:
- 优先专用工具而非通用手段:检索知识用检索工具、写产物用工件工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索知识用检索工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
- 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。
- 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。
- 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。
+1 -1
View File
@@ -12,4 +12,4 @@ description: 动态注入的当前环境事实(占位符随运行填充)
- 本轮附带文档:{{attachments}}。文档全文已注入上下文(超长时会标注截断)。
- 交付物默认样式:{{deliverableStyle}}。本轮的明确指令、上传的模板或参考文档优先于该默认值。
- 技能目录:{{skillsDir}}。环境变量 AGENT_SKILLS_DIR 指向这里;Python 已能 `import housestyle`。
- 本轮原件(工作区相对路径):{{inputs}}。模板和参考文档用这些路径,不要向用户索取已经上传的文件。
- 会话工作区文件(相对路径,跨轮有效):{{inputs}}。原件旁的 <原名>.md 是解析出的纯文本,需要原始信息时优先读它(不要只依赖你上一轮生成的产物,也不要向用户索取已经上传的文件)。模板和参考文档用这些路径。
@@ -0,0 +1,8 @@
---
id: tool.edit_file
layer: tool
version: 1
title: edit_file 工具描述
description: 工作区文件精确编辑的模型侧说明文案
---
对工作区文件做唯一匹配替换:old 必须与文件内容逐字一致(含缩进)且在文件中唯一,不唯一时带上更多上下文行。改前必须先 read 过该文件;文件在读取后被外部修改会拒绝,重读即可。多处相关修改用 multi_edit 一次提交,不要一行一个调用连发。
+8
View File
@@ -0,0 +1,8 @@
---
id: tool.glob
layer: tool
version: 1
title: glob 工具描述
description: 文件名模式匹配的模型侧说明文案
---
按通配模式找文件路径(** 跨目录),用于"这类文件有哪些/在哪个目录"。找内容用 grep,不要用 glob 后逐个 cat。
+8
View File
@@ -0,0 +1,8 @@
---
id: tool.grep
layer: tool
version: 1
title: grep 工具描述
description: 工作区内容检索的模型侧说明文案
---
在工作区文件内容里做正则检索(支持 include 按文件名过滤、ignore_case)。用于定位章节、函数、数据点;配合 read_file 的 offset 直达相关段落,替代整文件通读。
@@ -0,0 +1,8 @@
---
id: tool.read_file
layer: tool
version: 1
title: read_file 工具描述
description: 工作区文件读取的模型侧说明文案
---
读取会话工作区内的文本文件,输出带行号(1-based)。用法:先不带 offset/limit 读前 2000 行看全貌;文件大时用 offset/limit 分页,不要反复从头读。编辑前必须先读过目标文件。长输出会被截断并提示分页方式。
@@ -0,0 +1,8 @@
---
id: tool.run_python
layer: tool
version: 1
title: run_python 工具描述
description: Python 执行原语的模型侧说明文案
---
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`,不要在脚本里手工拼 docx。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排式的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
@@ -0,0 +1,8 @@
---
id: tool.run_shell
layer: tool
version: 1
title: run_shell 工具描述
description: bash 执行原语的模型侧说明文案
---
在会话工作区内执行 bash 脚本(环境最小化:PATH/HOME/LANG;AGENT_SKILLS_DIR 与 PYTHONPATH 已注入)。适用:housestyle build/lint、pip install --user、文件批操作。读文件用 read_file、找内容用 grep——不要用 cat/sed/grep 命令行代替专用工具。同一命令失败两次换方法。
+33
View File
@@ -0,0 +1,33 @@
package prompts
import (
"io/fs"
"regexp"
"strings"
"testing"
)
var barePythonRe = regexp.MustCompile(`(?:^|[^0-9A-Za-z_.-])python -(?:m|c)\b`)
// 提示词库里的命令必须能在执行环境跑起来:解释器只有 python3。
// 实测模型照提示词执行 `python -m housestyle.lint` 得到 exit 127。
func TestLibraryUsesRunnablePythonCommands(t *testing.T) {
names, err := fs.Glob(libraryFS, "library/*.md")
if err != nil {
t.Fatalf("glob: %v", err)
}
var problems []string
for _, name := range names {
raw, err := libraryFS.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
body := string(raw)
for _, loc := range barePythonRe.FindAllStringIndex(body, -1) {
problems = append(problems, name+": bare `python -` command near: "+strings.TrimSpace(body[loc[0]:min(loc[0]+60, len(body))]))
}
}
if len(problems) > 0 {
t.Fatalf("unrunnable python commands in prompt library:\n%s", strings.Join(problems, "\n"))
}
}
+3 -3
View File
@@ -73,9 +73,9 @@ func TestResolveSystemDefaultBase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// 默认:base 七块(identity/behavior/caution/style/tools/deliverables/quality)+ env 一块,无技能注入。
if len(blocks) != 8 {
t.Errorf("blocks = %d, want 8 (7 base + env)", len(blocks))
// 默认:base 八块(identity/behavior/method/caution/style/tools/deliverables/quality)+ env 一块,无技能注入。
if len(blocks) != 9 {
t.Errorf("blocks = %d, want 9 (8 base + env)", len(blocks))
}
var envBody string
for _, b := range blocks {
+54
View File
@@ -0,0 +1,54 @@
package quality
import (
"os"
"path/filepath"
"strings"
"testing"
)
// P5/裁定 4:拦截路径只认硬事实(lint 实际运行且零 error);
// 关键词维度(栏目式标题/可执行性等)不得再把合格交付物拦下来。
func TestHardFactsIgnoresKeywordDimensions(t *testing.T) {
dir := t.TempDir()
// 评分整体会因「结论先行 20/可读性 25」判负的文本——硬事实口径必须放行
//(无 lint 或 lint 通过时)。
path := filepath.Join(dir, "报告.md")
if err := os.WriteFile(path, []byte("# 概述\n\n正文内容。\n"), 0o644); err != nil {
t.Fatal(err)
}
full := ScoreFile(path)
if full.Passed {
t.Fatalf("keyword dimensions should still fail the full score (fixture sanity): %+v", full)
}
hard := HardFacts("报告.md", []byte("# 概述\n\n正文内容。\n"))
if !hard.Passed {
t.Fatalf("hard facts must ignore keyword dimensions: %+v", hard.Violations)
}
if len(hard.Lint) > 0 {
for _, issue := range hard.Lint {
if issue.Severity == "error" {
t.Fatalf("lint errors must still block: %+v", issue)
}
}
}
}
// lint 不可运行(技能根已配置)仍是硬事实失败——fail-visible 保持。
func TestHardFactsKeepsLintUnavailableBlocking(t *testing.T) {
SetLintEnvRoot(t.TempDir())
t.Cleanup(func() { SetLintEnvRoot("") })
hard := HardFacts("报告.md", []byte("# 概述\n正文。\n"))
if hard.Passed {
t.Fatalf("lint-not-run must stay blocking: %+v", hard)
}
found := false
for _, v := range hard.Violations {
if strings.Contains(v, "自检未运行") {
found = true
}
}
if !found {
t.Fatalf("expected 自检未运行, got %v", hard.Violations)
}
}
+194 -12
View File
@@ -26,12 +26,15 @@ type Dimension struct {
}
// Report 是一次评分。Passed 要求总分不低于 70,且没有任何一维低于 40。
// LintRan 为假表示 housestyle.lint 没有实际执行(环境不可用);技能根已注入
// (SetLintEnvRoot)时这本身计为一条 Violations,不再把「没跑」静默当「没问题」。
type Report struct {
Passed bool `json:"passed"`
Score int `json:"score"`
Dimensions []Dimension `json:"dimensions"`
Violations []string `json:"violations,omitempty"`
Lint []LintIssue `json:"lint,omitempty"`
LintRan bool `json:"lintRan,omitempty"`
}
// LintIssue 是 housestyle.lint 的一条结果。lint 不存在时留空,不因此判失败。
@@ -41,6 +44,17 @@ type LintIssue struct {
Message string `json:"message"`
}
// lintEnvRoot 由组合根在启动时注入(<data>/skills)。零值保持旧语义:
// 继承进程环境,lint 不可用时静默跳过(测试与无技能部署)。
var lintEnvRoot string
// SetLintEnvRoot 注入技能根:lint 子进程获得与 run_python 一致的
// AGENT_SKILLS_DIR / PYTHONPATH,否则 housestyle 在后端进程里 import 失败。
// 非空根同时把「lint 必须可运行」设为强约束:跑不起来即 Violations(fail-visible)。
func SetLintEnvRoot(skillsRoot string) {
lintEnvRoot = skillsRoot
}
// Judge 用模型给同一份正文打分。返回错误时调用方回落启发式。
type Judge func(ctx context.Context, text string) (Report, error)
@@ -82,20 +96,27 @@ func ScoreText(text string) Report {
// ScoreFile 读取文件正文再打分。docx 取段落文本;其他文件按 UTF-8 文本读。
// 能跑 housestyle.lint 时,error 级问题会使 Passed 变为 false。
// lint 无法执行时:技能根已注入(SetLintEnvRoot)→ 记 Violations「自检未运行」
// 并判不通过(fail-visible);未注入(测试/无技能部署)→ 保持旧的静默跳过。
func ScoreFile(path string) Report {
text, err := readDeliverable(path)
if err != nil {
return Report{Passed: false, Violations: []string{"无法读取交付物:" + err.Error()}}
}
report := ScoreText(text)
issues := lintFile(path)
issues, lintErr := lintFile(path)
report.Lint = issues
report.LintRan = lintErr == nil
for _, issue := range issues {
if issue.Severity == "error" {
report.Passed = false
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
}
}
if lintErr != nil && lintEnvRoot != "" {
report.Passed = false
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用:"+lintErr.Error()+")")
}
return report
}
@@ -176,11 +197,22 @@ func scoreActionable(text string) Dimension {
return Dimension{ID: "可执行性", Score: 35, Note: "没有可交给人的行动", Evidence: snippet(text, 40)}
}
// scoreReadable 只检查真正的标题行(# 开头;docx 路径已把标题样式标成 #)。
// 此前对每一行做「栏目名词 + 短行」判断,表格单元格(如「项目概述撰写」)
// 会被当成标题误报,终轮检查因此对合格文档连续判负(实测死循环源之一)。
// 命中时 Note 带行号,模型能定位到要改的那一行。
func scoreReadable(text string) Dimension {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(strings.TrimLeft(line, "#"))
if bannedTitle(line) && utf8.RuneCountInString(line) <= 12 {
return Dimension{ID: "可读性", Score: 25, Note: "使用了栏目式标题", Evidence: line}
for i, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "#") {
continue
}
title := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
if title == "" {
continue
}
if bannedTitle(title) && utf8.RuneCountInString(title) <= 12 {
return Dimension{ID: "可读性", Score: 25, Note: fmt.Sprintf("第 %d 行标题是栏目名,不是判断,改成一句结论", i+1), Evidence: title}
}
}
if strings.Contains(text, "。。") {
@@ -251,6 +283,9 @@ func readDeliverable(path string) (string, error) {
return string(data), nil
}
// readDocxText 抽取 docx 纯文本,并把标题样式(Title / HeadingN)的段落标成
// Markdown # 前缀——评分卡据此区分「标题行」与「正文/表格单元格」,也让
// scoreLead 等维度拿到真实的标题信息。
func readDocxText(path string) (string, error) {
zr, err := zip.OpenReader(path)
if err != nil {
@@ -274,6 +309,22 @@ func readDocxText(path string) (string, error) {
defer rc.Close()
decoder := xml.NewDecoder(rc)
var b strings.Builder
var para strings.Builder
style := ""
inPara := false
flush := func() {
text := strings.TrimSpace(para.String())
if text != "" {
if lvl := headingLevel(style); lvl > 0 {
b.WriteString(strings.Repeat("#", min(lvl, 6)))
b.WriteByte(' ')
}
b.WriteString(text)
b.WriteByte('\n')
}
para.Reset()
style = ""
}
for {
tok, err := decoder.Token()
if err != nil {
@@ -281,23 +332,70 @@ func readDocxText(path string) (string, error) {
}
switch el := tok.(type) {
case xml.StartElement:
if el.Name.Local == "p" && b.Len() > 0 {
b.WriteByte('\n')
switch el.Name.Local {
case "p":
inPara = true
para.Reset()
style = ""
case "pStyle":
if inPara {
for _, a := range el.Attr {
if a.Name.Local == "val" {
style = a.Value
}
}
}
}
case xml.EndElement:
if el.Name.Local == "p" {
inPara = false
flush()
}
case xml.CharData:
b.Write(bytes.TrimSpace(el))
if inPara {
para.Write(bytes.TrimSpace(el))
}
}
}
return b.String(), nil
flush()
return strings.TrimRight(b.String(), "\n"), nil
}
// headingLevel 把 docx styleId 映射为标题层级:Heading1..9 → 1..9,
// Title → 1,Subtitle → 2;其余(含表格单元格正文)返回 0。
func headingLevel(style string) int {
switch {
case style == "Title":
return 1
case style == "Subtitle":
return 2
case strings.HasPrefix(style, "Heading"):
n := 0
for _, c := range style[len("Heading"):] {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
if n >= 1 && n <= 9 {
return n
}
}
return 0
}
var lintJSON = regexp.MustCompile(`(?s)\[.*\]`)
func lintFile(path string) []LintIssue {
// lintFile 运行 housestyle.lint。返回 (issues, nil) 表示 lint 实际执行;
// 非 nil err 表示没有跑起来(解释器缺失、模块不可导入等),调用方决定是否
// 按「自检未运行」暴露。env 与 run_python 的 execEnv 同源语义:继承最小
// 环境并附加 AGENT_SKILLS_DIR / PYTHONPATH,否则后端进程里 import 不了 housestyle。
func lintFile(path string) ([]LintIssue, error) {
cmd := exec.Command("python3", "-m", "housestyle.lint", path)
cmd.Env = lintCommandEnv()
out, err := cmd.CombinedOutput()
if err != nil && !bytes.Contains(out, []byte("\"rule\"")) {
return nil
return nil, fmt.Errorf("%s", firstLine(string(out)))
}
raw := out
if loc := lintJSON.Find(out); loc != nil {
@@ -305,7 +403,91 @@ func lintFile(path string) []LintIssue {
}
var issues []LintIssue
if json.Unmarshal(raw, &issues) != nil {
return nil, fmt.Errorf("lint 输出无法解析:%s", firstLine(string(out)))
}
return issues, nil
}
// lintCommandEnv 继承最小环境(PATH/HOME/LANG 等,与 execEnv 的继承面一致)。
// 技能根注入后附加 AGENT_SKILLS_DIR 与 PYTHONPATH=<root>/house-style;进程已有
// PYTHONPATH 时前置合并(测试与二次包装场景不丢自己的路径)。
// lintEnvRoot 为空时返回 nil(不设 cmd.Env,完整继承,保持旧行为)。
func lintCommandEnv() []string {
if lintEnvRoot == "" {
return nil
}
return issues
var out []string
var existingPath string
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "PYTHONPATH=") {
existingPath = strings.TrimPrefix(kv, "PYTHONPATH=")
continue
}
for _, keep := range []string{"PATH=", "HOME=", "LANG=", "LC_", "TZ=", "TERM="} {
if strings.HasPrefix(kv, keep) {
out = append(out, kv)
break
}
}
}
pp := filepath.Join(lintEnvRoot, "house-style")
if existingPath != "" {
pp = pp + string(os.PathListSeparator) + existingPath
}
out = append(out,
"AGENT_SKILLS_DIR="+lintEnvRoot,
"PYTHONPATH="+pp,
)
return out
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if utf8.RuneCountInString(s) > 160 {
s = string([]rune(s)[:160]) + "…"
}
return s
}
// HardFacts 只评可机械判定的硬事实:lint 实际运行且零 error。
// 关键词维度(ScoreText 六维)从拦截路径移出(裁定 4/P5):它们描述的是
// 写作质量偏好,实测会把正常中文章节标题判成栏目名、把没有"未指明"字样的
// 行动表判成不可执行,造成终轮死循环;评分整体仍由 ScoreBytes 供评测侧使用。
func HardFacts(name string, content []byte) Report {
report := ScoreBytes(name, content)
report.Passed = true
report.Violations = nil
for _, issue := range report.Lint {
if issue.Severity == "error" {
report.Passed = false
report.Violations = append(report.Violations, "版式 "+issue.Rule+":"+issue.Message)
}
}
if !report.LintRan && lintEnvRoot != "" {
report.Passed = false
report.Violations = append(report.Violations, "自检未运行(housestyle.lint 不可用)")
}
return report
}
// DeliverableText 供事实核查抽取工件文本(docx 与 ScoreBytes 同源口径;
// 二进制返回空)。导出给 runtime 包使用。
func DeliverableText(name string, content []byte) string {
dir, err := os.MkdirTemp("", "factcheck")
if err != nil {
return ""
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, name)
if err := os.WriteFile(path, content, 0o644); err != nil {
return ""
}
text, err := readDeliverable(path)
if err != nil {
return ""
}
return text
}
+132
View File
@@ -0,0 +1,132 @@
package quality
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLintCommandEnvCarriesSkillsRoot(t *testing.T) {
root := t.TempDir()
SetLintEnvRoot(root)
t.Cleanup(func() { SetLintEnvRoot("") })
env := lintCommandEnv()
var sawSkills, sawPath bool
for _, kv := range env {
if kv == "AGENT_SKILLS_DIR="+root {
sawSkills = true
}
if kv == "PYTHONPATH="+filepath.Join(root, "house-style") {
sawPath = true
}
}
if !sawSkills || !sawPath {
t.Fatalf("lint env missing skills root entries: %v", env)
}
}
// 技能根已注入但 housestyle 不可导入时,评分必须显式报「自检未运行」,
// 不能把没跑当成没问题(实测:后端进程无 PYTHONPATH,lint 静默失效)。
func TestLintUnavailableFailsVisibleWhenSkillsRootSet(t *testing.T) {
SetLintEnvRoot(t.TempDir())
t.Cleanup(func() { SetLintEnvRoot("") })
good := "# 上传文档应先判断相关性再入库\n\n## 先校验,再生成\n- 说话人1:上传后第一步是判断是否与项目相关。\n一句话:没有记忆库,后面的生成和追溯都没有依据。\n\n## 下一步\n- 事项:写出第一版需求。负责人:陈威。期限:周五前;说不清的写未指明。\n"
rep := ScoreBytes("note.md", []byte(good))
if rep.Passed {
t.Fatalf("lint unavailable must fail visibly when skills root is set: %+v", rep)
}
found := false
for _, v := range rep.Violations {
if strings.Contains(v, "自检未运行") {
found = true
}
}
if !found {
t.Fatalf("expected 自检未运行 violation, got: %v", rep.Violations)
}
if rep.LintRan {
t.Fatalf("LintRan should be false when lint could not run")
}
}
// docx 标题样式要被标成 # 前缀;表格单元格/正文不参与「栏目式标题」判定
// (实测误报:单元格「项目概述撰写」被当成标题,检查连续判负)。
func TestDocxHeadingMarkedAndTableCellNotFlagged(t *testing.T) {
docx := buildDocx(t, []paraSpec{
{style: "Heading1", text: "概述"},
{style: "", text: "项目概述撰写"},
{style: "Heading2", text: "先把需求写清楚再开发"},
})
text, err := readDocxText(docx)
if err != nil {
t.Fatalf("readDocxText: %v", err)
}
if !strings.Contains(text, "# 概述") || !strings.Contains(text, "## 先把需求写清楚再开发") {
t.Fatalf("headings not marked with #: %q", text)
}
if !strings.Contains(text, "项目概述撰写") {
t.Fatalf("body paragraph missing: %q", text)
}
// 标题是栏目名 → 仍要被判低分。
withBadHeading := ScoreText(text)
if withBadHeading.Passed {
t.Fatalf("banned heading should fail: %+v", withBadHeading)
}
// 同一个「概述」字样只出现在非标题行时,不应触发栏目式标题。
cellOnly := buildDocx(t, []paraSpec{
{style: "Heading1", text: "先把需求写清楚再开发"},
{style: "", text: "项目概述撰写"},
{style: "", text: "一句话:先写需求。负责人:陈威。期限:周五前。"},
})
cellText, err := readDocxText(cellOnly)
if err != nil {
t.Fatalf("readDocxText: %v", err)
}
for _, d := range ScoreText(cellText).Dimensions {
if d.ID == "可读性" && d.Score < 60 {
t.Fatalf("cell text must not be flagged as column heading: %+v", d)
}
}
}
type paraSpec struct {
style string
text string
}
// buildDocx 构造最小 docx:只含 word/document.xml(readDocxText 的唯一输入)。
func buildDocx(t *testing.T, paras []paraSpec) string {
t.Helper()
var b strings.Builder
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
for _, p := range paras {
b.WriteString(`<w:p>`)
if p.style != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="` + p.style + `"/></w:pPr>`)
}
b.WriteString(`<w:r><w:t>` + p.text + `</w:t></w:r></w:p>`)
}
b.WriteString(`</w:body></w:document>`)
path := filepath.Join(t.TempDir(), "test.docx")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create docx: %v", err)
}
defer f.Close()
w := zip.NewWriter(f)
entry, err := w.Create("word/document.xml")
if err != nil {
t.Fatalf("zip entry: %v", err)
}
if _, err := entry.Write([]byte(b.String())); err != nil {
t.Fatalf("zip write: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return path
}
+6
View File
@@ -48,6 +48,12 @@ type embeddingsResponse struct {
} `json:"error,omitempty"`
}
// Model 当前 embedding 模型身份(U6-3:写入与检索同源;未配置返回空)。
func (e *HTTPEmbedder) Model() string {
route := e.resolve(context.Background())
return route.Model
}
func (e *HTTPEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
+3
View File
@@ -13,6 +13,9 @@ const hashDim = 32
// 由 settings models.embedding.provider=hash 显式打开;未设置时仍按 D23 退化为全文。
type HashEmbedder struct{}
// Model 本地哈希嵌入的身份(U6-3:同源写入/检索,禁止与真实模型混查)。
func (HashEmbedder) Model() string { return "hash:" + fmt.Sprint(hashDim) }
func (HashEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
out := make([][]float32, len(texts))
for i, text := range texts {
+21 -3
View File
@@ -2,6 +2,8 @@ package rag
import (
"context"
"crypto/sha256"
"encoding/hex"
"log"
"agent-system-backend/internal/settings"
@@ -104,17 +106,33 @@ func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.K
log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err)
return nil
}
model := embedderModel(p.embedder)
for i, v := range vecs {
if len(v) == 0 {
continue
}
if err := p.st.UpsertKnowledgeVector(ctx, owner, docs[i].ID, docs[i].Ref, v); err != nil {
if err := p.st.UpsertKnowledgeVectorExt(ctx, owner, docs[i].ID, docs[i].Ref, model, contentHash(docs[i].Chunk), v); err != nil {
return err
}
}
return nil
}
// embedderModel 模型身份(可选接口:EmbeddingProvider 契约未含,实现侧
// 声明;未声明=空=legacy 无身份行,向后兼容)。
func embedderModel(e core.EmbeddingProvider) string {
if m, ok := e.(interface{ Model() string }); ok {
return m.Model()
}
return ""
}
// contentHash 分块内容摘要(写入幂等核对用;sha256 前 16 字节十六进制)。
func contentHash(chunk string) string {
sum := sha256.Sum256([]byte(chunk))
return hex.EncodeToString(sum[:])[:16]
}
func firstRef(docs []core.KnowledgeDoc) string {
for _, d := range docs {
if d.Ref != "" {
@@ -143,7 +161,7 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret
if topK <= 0 {
topK = 5
}
vs, e2 := p.st.SearchKnowledgeVectorsShared(ctx, string(owner), qvecs[0], topK, q, allow)
vs, e2 := p.st.SearchKnowledgeVectorsSharedModel(ctx, string(owner), embedderModel(p.embedder), qvecs[0], topK, q, allow)
if e2 == nil {
shared = rrfMerge(vs, shared)
}
@@ -179,7 +197,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
return p.st.SearchKnowledge(owner, q)
}
q.TopK = topK * 2 // 每路多取,融合后再裁剪
vhits, err := p.st.SearchKnowledgeVectors(ctx, owner, qvecs[0], topK*2, q)
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
if err != nil {
return nil, err
}
+22 -7
View File
@@ -6,6 +6,7 @@ package ratelimit
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
@@ -52,14 +53,26 @@ func (m *Memory) Allow(_ context.Context, key string, limit int, window time.Dur
return true
}
// ---- Redis 固定窗口(INCR + EXPIRE;首击设窗)----
// ---- Redis 固定窗口(Lua 原子 INCR+PEXPIRE;故障降级进程内保守限流)----
// incrExpire 原子计数并只在首击设窗(协作存储 U3,KTD10):两步 INCR/EXPIRE
// 在 EXPIRE 失败或进程于其间退出时会留下永不过期的键;Lua 单脚本消除该窗口。
var incrExpire = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return n
`)
type Redis struct {
rdb *redis.Client
// fallback:Redis 故障时的进程内保守限流(同限值、按实例计数——是共享
// 限流的近似而非替代;昂贵入口的硬保护在 PG admission,不依赖本层)。
fallback *Memory
degraded atomic.Int64
}
func NewRedis(addr, password string, db int) *Redis {
return &Redis{rdb: redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db})}
return &Redis{rdb: redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db}), fallback: NewMemory()}
}
func (r *Redis) Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
@@ -67,16 +80,18 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int, window time.Du
return true
}
rkey := "rl:" + key + ":" + time.Now().UTC().Truncate(window).Format("150405")
n, err := r.rdb.Incr(ctx, rkey).Result()
n, err := incrExpire.Run(ctx, r.rdb, []string{rkey}, window.Milliseconds()).Int64()
if err != nil {
return true // Redis 故障不阻断请求(限流是柔性行为,放行并记日志于调用方)
}
if n == 1 {
r.rdb.Expire(ctx, rkey, window+time.Second)
// 降级(KTD10):不再无界放行;按实例保守限流并计数供观测告警。
r.degraded.Add(1)
return r.fallback.Allow(ctx, key, limit, window)
}
return n <= int64(limit)
}
// Degraded 返回累计降级次数(观测:非零说明 Redis 不可用,需运维介入)。
func (r *Redis) Degraded() int64 { return r.degraded.Load() }
func (r *Redis) Ping(ctx context.Context) error { return r.rdb.Ping(ctx).Err() }
func (r *Redis) Close() error { return r.rdb.Close() }
+47
View File
@@ -55,3 +55,50 @@ func TestRedisWindow(t *testing.T) {
t.Fatal("3rd request must be limited")
}
}
// Lua 原子窗口(U3,KTD10):计数与设窗单脚本完成,键必然带 TTL——
// 窗口翻转后重新放行(原子性回归:旧两步实现窗口语义相同,此处锚定
// 「INCR 与 PEXPIRE 不可分」的行为面)。
func TestRedisWindowAtomicScript(t *testing.T) {
addr := os.Getenv("AGENT_REDIS_TEST_ADDR")
if addr == "" {
t.Skip("AGENT_REDIS_TEST_ADDR not set")
}
r := NewRedis(addr, "", 15)
defer r.Close()
ctx := context.Background()
key := "atomic-" + time.Now().Format("150405.000000000")
window := 300 * time.Millisecond
for i := 1; i <= 3; i++ {
if !r.Allow(ctx, key, 3, window) {
t.Fatalf("request %d should pass", i)
}
}
if r.Allow(ctx, key, 3, window) {
t.Fatal("4th request must be limited")
}
time.Sleep(window + 50*time.Millisecond)
if !r.Allow(ctx, key, 3, window) {
t.Fatal("new window must allow again")
}
}
// Redis 故障降级(KTD10):不可达时不再无界放行——按实例保守限流;
// Degraded 计数供观测告警。
func TestRedisDegradedFallback(t *testing.T) {
r := NewRedis("127.0.0.1:1", "", 15) // 不可达端口
defer r.Close()
ctx := context.Background()
key := "degraded-" + time.Now().Format("150405.000000000")
for i := 1; i <= 2; i++ {
if !r.Allow(ctx, key, 2, time.Minute) {
t.Fatalf("degraded request %d should pass (local limit 2)", i)
}
}
if r.Allow(ctx, key, 2, time.Minute) {
t.Fatal("degraded 3rd request must be limited locally")
}
if r.Degraded() == 0 {
t.Fatal("degradation must be observable")
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package route
import (
"testing"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentgate"
core "agent-system-core"
)
+122
View File
@@ -0,0 +1,122 @@
package runmgr
import (
"context"
"errors"
"log"
"sync"
"agent-system-backend/internal/store"
)
// 协作存储 U3 切片 2:执行许可门(R8)。预算经 budgetFn 动态读取(设置
// runtime.execBudgetGlobal / runtime.execBudgetPerUser,默认全零=不限,本地档
// 行为与历史一致)。许可的共享权威在 store.admission_leases(PG 咨询锁下
// 原子清点);本门只负责有界排队(接纳数)与让出/重取(父等待子代理或
// HITL 时让出执行许可、保留会话所有权——避免额度占尽时父子互等)。
// ErrAdmissionQueueFull 待执行队列已满(接纳被拒;HTTP 层映射 429)。
var ErrAdmissionQueueFull = errors.New("runmgr: execution queue full")
// admissionGate 每进程一个;释放时广播唤醒全部等待者(无公平队列,Golang
// cond 语义;公平分配留 U7 压测后按实测加)。
type admissionGate struct {
mu sync.Mutex
broadcast chan struct{} // 释放时 close+重建
waiters int
budgetFn func() store.ExecutionBudget
queueMax int
}
func newAdmissionGate(budgetFn func() store.ExecutionBudget, queueMax int) *admissionGate {
if budgetFn == nil {
budgetFn = func() store.ExecutionBudget { return store.ExecutionBudget{} }
}
if queueMax <= 0 {
queueMax = 200 // 方案容量合同的默认队列上限(待实测调整)
}
return &admissionGate{broadcast: make(chan struct{}), budgetFn: budgetFn, queueMax: queueMax}
}
// budget 加锁读取当前预算(与 SetExecutionBudgetFn 并发安全)。
func (g *admissionGate) budget() store.ExecutionBudget {
if g == nil {
return store.ExecutionBudget{} // 直接构造的 Manager(测试):预算关闭
}
g.mu.Lock()
defer g.mu.Unlock()
return g.budgetFn()
}
// QueueDepth 返回当前排队数(诊断/验收)。
func (g *admissionGate) QueueDepth() int {
if g == nil {
return 0
}
g.mu.Lock()
defer g.mu.Unlock()
return g.waiters
}
// QueueFull 非阻塞探测:排队是否已满(API 边缘在落库前拒绝,429 且不写数据)。
func (g *admissionGate) QueueFull() bool {
budget := g.budget()
if budget.Off() {
return false
}
g.mu.Lock()
defer g.mu.Unlock()
return g.waiters >= g.queueMax
}
// acquire 阻塞获取执行许可(ctx 取消则放弃排队)。预算关闭时零开销直通。
func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string) error {
if g == nil {
return nil
}
budget := g.budget()
if budget.Off() {
return nil
}
for {
if err := st.TryAcquireExecutionSlot(runID, owner, budget); err == nil {
return nil
} else if !errors.Is(err, store.ErrBudgetExceeded) {
return err // 存储故障:不放行(fail-closed,宁可拒绝执行)
}
g.mu.Lock()
if g.waiters >= g.queueMax {
g.mu.Unlock()
return ErrAdmissionQueueFull
}
g.waiters++
ch := g.broadcast
g.mu.Unlock()
select {
case <-ch:
g.mu.Lock()
g.waiters--
g.mu.Unlock()
case <-ctx.Done():
g.mu.Lock()
g.waiters--
g.mu.Unlock()
return ctx.Err()
}
}
}
// release 释放执行许可并广播(幂等;预算关闭时零开销)。
func (g *admissionGate) release(st *store.Store, runID string) {
if g == nil || g.budget().Off() {
return
}
if err := st.ReleaseExecutionSlot(runID); err != nil {
// 释放失败只影响账本整洁(下次复活即修正),不中断运行。
log.Printf("[runmgr] admission release %s: %v", runID, err)
}
g.mu.Lock()
close(g.broadcast)
g.broadcast = make(chan struct{})
g.mu.Unlock()
}
+140
View File
@@ -0,0 +1,140 @@
package runmgr
import (
"context"
"errors"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 协作存储 U3 切片 2(R8):执行预算在 runmgr 侧的行为面——
// 子代理计入全局预算、父让出避免父子互等、队列有界(429 拒绝面)。
// 同步子代理:父占满全局预算仍能派生前进(父让出 → 子获取 → 子完成 →
// 父重取),不发生互等死锁。
func TestSyncSpawnUnderSaturatedBudgetMakesProgress(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "子结果"}}}
c, st := newSpawnEnv(t, runner)
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
c.mgr.SetExecutionBudgetFn(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 10)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
// 父先占住唯一的执行许可(等价于一个正在执行的父 run)。
if err := st.TryAcquireExecutionSlot(string(parentRun.ID), parentRun.Owner, store.ExecutionBudget{Global: 1}); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
_, err := c.mgr.spawnSubagent(context.Background(), parentRun, "agent.sub.explore", "检索", "任务", "")
done <- err
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("饱和预算下同步派生应前进:%v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("父满额派生死锁:子代理 10s 未完成(让出语义失效)")
}
// 父重取成功:租约复活。
if held, _, err := st.ActiveExecutionSlots(); err != nil || held != 1 {
t.Fatalf("父应重取唯一许可:held=%d err=%v", held, err)
}
}
// 后台子代理计入全局预算:预算 1 时第二个后台子代理排队,第一个完成后
// 才执行(不超卖)。
func TestBackgroundChildrenShareBudget(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
runner := &gateRunner{entered: entered, release: release, parts: []core.Part{{Type: core.PartText, Text: "结果"}}}
c, st := newSpawnEnv(t, runner)
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
c.mgr.SetExecutionBudgetFn(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 10)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
first, err := c.mgr.spawnBackground(parentRun, "agent.sub.explore", "A", "任务A", "")
if err != nil {
t.Fatal(err)
}
<-entered // 第一个子代理已进入执行(持有唯一许可)。
second, err := c.mgr.spawnBackground(parentRun, "agent.sub.explore", "B", "任务B", "")
if err != nil {
t.Fatal(err)
}
// 第二个应处于排队(无活跃租约可用)——账本里只有 first。
if held, _, _ := st.ActiveExecutionSlots(); held != 1 {
t.Fatalf("预算 1 时不应有两个活跃许可:%d", held)
}
close(release)
// 第一个完成后第二个获得许可并执行完毕(entered 的 once 已用掉,用
// 账本最终归零 + 等待窗口断言)。
deadline := time.Now().Add(10 * time.Second)
for {
held, _, _ := st.ActiveExecutionSlots()
if held == 0 && c.mgr.QueueDepth() == 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("第二个后台子代理未完成:held=%d queue=%d", held, c.mgr.QueueDepth())
}
time.Sleep(50 * time.Millisecond)
}
_ = first
_ = second
}
// 队列有界:预算占满、队列达上限后 QueueFull() 为真(API 边缘 429 拒绝面),
// acquire 返回 ErrAdmissionQueueFull;释放后排队者获得许可。
func TestAdmissionQueueBounded(t *testing.T) {
st := openTestStoreQuiet(t)
g := newAdmissionGate(func() store.ExecutionBudget { return store.ExecutionBudget{Global: 1} }, 1)
ctx := context.Background()
if err := g.acquire(ctx, st, "run_hold", "o"); err != nil {
t.Fatal(err)
}
queued := make(chan error, 1)
go func() { queued <- g.acquire(ctx, st, "run_wait", "o") }()
// 等待排队者入队(深度 1)。
deadline := time.Now().Add(5 * time.Second)
for g.QueueDepth() == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !g.QueueFull() {
t.Fatal("队列达上限应 QueueFull")
}
if err := g.acquire(ctx, st, "run_over", "o"); !errors.Is(err, ErrAdmissionQueueFull) {
t.Fatalf("超队列应 ErrAdmissionQueueFull:%v", err)
}
g.release(st, "run_hold")
if err := <-queued; err != nil {
t.Fatalf("排队者应获得许可:%v", err)
}
g.release(st, "run_wait")
}
// openTestStoreQuiet:runmgr 测试包里建一个不迁移断言的临时库(沿用
// recovery_test 的 openRecoveryStore 会走双档;这里只需本地档)。
func openTestStoreQuiet(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(t.TempDir() + "/adm.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
var _ = core.Run{} // 保持 import 锚(newSpawnEnv 依赖 core)
+164
View File
@@ -0,0 +1,164 @@
package runmgr
import (
"errors"
"strings"
"sync"
"testing"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/runtime"
core "agent-system-core"
)
// newCardSpawnEnv 在后台派发环境上接线收件箱卡片服务,并捕获发出的卡片事件
// (任务快慢双速:慢通道进度卡 → 完成回投的验证环境)。
func newCardSpawnEnv(t *testing.T, runner runtime.Runner) (*runCtx, *[]string) {
t.Helper()
c, _ := newSpawnEnv(t, runner)
var mu sync.Mutex
var events []string
c.mgr.cards = cardsvc.New(c.mgr.st, func(conversationID, runID string, typ core.StreamEventType, payload any) error {
mu.Lock()
defer mu.Unlock()
events = append(events, string(typ))
return nil
})
return c, &events
}
func cardEvents(list *[]string) []string {
var out []string
for _, e := range *list {
if strings.HasPrefix(e, "card.") {
out = append(out, e)
}
}
return out
}
func TestBackgroundCardLifecycleOnSuccess(t *testing.T) {
runner := &captureRunner{parts: []core.Part{
{Type: core.PartText, Text: "深度解析完成:三份材料均相关,要点已汇总。"},
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "art_0123456789ab", Name: "解析摘要.md"}},
}}
c, events := newCardSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
if len(systemTexts(t, c)) != 1 {
return false
}
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(cards) != 1 {
t.Fatalf("cards = %d, want 1", len(cards))
}
card := cards[0]
if card.Kind != core.CardKindProgress || card.Title != "深度解析员" {
t.Fatalf("card = %+v", card)
}
if card.Status != core.CardDone || card.Tone != "ok" {
t.Fatalf("status = %s tone = %s, want done/ok", card.Status, card.Tone)
}
var md string
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
}
if !strings.Contains(md, "深度解析完成") || !strings.Contains(md, "解析摘要.md") {
t.Fatalf("result body = %q", md)
}
if card.SourceRunID == "" {
t.Fatal("card not linked to child run")
}
got := cardEvents(events)
for _, want := range []string{"card.created", "card.updated", "card.status"} {
found := false
for _, e := range got {
if e == want {
found = true
}
}
if !found {
t.Fatalf("events %v missing %s", got, want)
}
}
}
func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
runner := &gateRunner{err: errors.New("boom: 材料解析失败")}
c, _ := newCardSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
if len(systemTexts(t, c)) != 1 {
return false
}
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(cards) != 1 {
t.Fatalf("cards = %d, want 1", len(cards))
}
card := cards[0]
if card.Status != core.CardDone || card.Tone != "danger" {
t.Fatalf("status = %s tone = %s, want done/danger", card.Status, card.Tone)
}
var md string
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
}
if !strings.Contains(md, "执行失败") {
t.Fatalf("failure body = %q", md)
}
}
// TestBackgroundCardSkippedWithoutInbox:收件箱未启用(cards==nil)时后台派发
// 不建卡也不报错——既有 background_test 环境即此形态,这里显式断言不回归。
func TestBackgroundCardSkippedWithoutInbox(t *testing.T) {
runner := &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if c.mgr.cards != nil {
t.Fatal("env unexpectedly wired cards")
}
if _, err := c.SpawnBackground("agent.sub.general", "后台", "任务", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool { return len(systemTexts(t, c)) == 1 })
}
// TestPartsTextStripsInlineThink:MiniMax 行内 <think> 块不进子代理摘要/回投卡。
func TestPartsTextStripsInlineThink(t *testing.T) {
got := partsText([]core.Part{{Type: core.PartText, Text: "<think>先读文件</think>\n正文开始\n<think>再想</think>结论"}})
if strings.Contains(got, "<think>") || strings.Contains(got, "先读文件") || strings.Contains(got, "再想") {
t.Fatalf("think leaked: %q", got)
}
if !strings.Contains(got, "正文开始") || !strings.Contains(got, "结论") {
t.Fatalf("body lost: %q", got)
}
if got := partsText([]core.Part{{Type: core.PartText, Text: "半截 <think> 未闭合保守保留"}}); !strings.Contains(got, "半截") {
t.Fatalf("unclosed think should keep text: %q", got)
}
}
+84
View File
@@ -0,0 +1,84 @@
package runmgr
// EXT-14 红测(提案 2026-10-09-ext-g1-b1-b3 §2):带 exposeAsTool 的命令投影为
// 回合内工具;模型调用经命令总分发(Entrance="agent"、同审计信封);不带字段的
// 命令不出现;高危命令在对话内弹内联确认卡(HITL 收口不变)。
import (
"context"
"testing"
"agent-system-backend/internal/tools"
core "agent-system-core"
)
// fakeBusSource EXT-14 测试桩:命令清单 + 分发记录(说话用 runmgr 视图类型,
// 与生产代码同口径——runmgr 不依赖 cmdbus)。
type fakeBusSource struct {
cmds []CommandTool
lastReq CommandRequest
resp CommandResponse
}
func (f *fakeBusSource) CommandTools() []CommandTool { return f.cmds }
func (f *fakeBusSource) CommandToolsFor(owner string) []CommandTool { return f.cmds }
func (f *fakeBusSource) DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse {
f.lastReq = req
return f.resp
}
func TestCommandToolsProjected(t *testing.T) {
rc0, _ := newExecEnv(t)
m := rc0.mgr
src := &fakeBusSource{cmds: []CommandTool{
{ID: "rdm.trace", Description: "查追溯网", Params: []byte(`{"type":"object"}`), Risk: "low"},
}}
m.SetCommandSource(src)
rc := rc0
tools := rc.Tools()
found := map[string]bool{}
for _, d := range tools {
found[d.Name] = true
}
if !found["rdm.trace"] {
t.Fatalf("exposeAsTool 命令未投影为工具: %+v", tools)
}
if found["rdm.hidden"] {
t.Fatal("命令源之外的工具不得出现")
}
}
func TestCommandToolDispatchesThroughBus(t *testing.T) {
rc0, _ := newExecEnv(t)
m := rc0.mgr
src := &fakeBusSource{
cmds: []CommandTool{{ID: "rdm.trace", Params: []byte(`{"type":"object"}`), Risk: "low"}},
resp: CommandResponse{State: "committed"},
}
m.SetCommandSource(src)
rc := rc0
res := rc.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "rdm.trace", Arguments: []byte(`{"q":"x"}`)})
if res.Status != "ok" {
t.Fatalf("dispatch result = %s %s", res.Status, res.Output)
}
req := src.lastReq
if req.ID != "rdm.trace" || req.Entrance != "agent" || req.Actor != rc.run.Owner || req.Owner != rc.run.Owner || req.ConversationID != rc.run.ConversationID {
t.Fatalf("bus request = %+v(须 Entrance=agent、Actor/Owner=会话 owner、带会话)", req)
}
if req.Params["q"] != "x" {
t.Fatalf("params = %+v", req.Params)
}
// 未注册命令源时:调用按注册表口径报错(现状),不 panic。
reg2 := tools.NewRegistry()
if err := reg2.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
m2 := New(nil, nil, reg2, nil, nil, nil, nil, nil, nil)
rc2 := &runCtx{mgr: m2, run: core.Run{Owner: "bob"}, prof: core.AgentProfile{Tools: core.ToolPolicy{Allow: []string{"*"}}}}
if res := rc2.ExecuteTool(core.ToolCallRequest{CallID: "c2", Name: "rdm.trace", Arguments: []byte(`{}`)}); res.Status != "error" {
t.Fatalf("no source: %s(须 registry miss 报错)", res.Status)
}
}
+178
View File
@@ -0,0 +1,178 @@
package runmgr
import (
"context"
"encoding/json"
"log"
"time"
core "agent-system-core"
)
// 协作存储 U5(KTD5):持久命令消费。任意 API 实例把取消/HITL 写入
// run_commands 收件箱;命令由「目标 run 的当前执行实例」消费(路由依据
// conversation_executions 的当前 owner;本地不在执行的实例跳过,命令保持
// pending 直到执行实例领取或超时由恢复路径收口)。
//
// 本文件是执行侧消费循环;server 侧的投递入口见 server 包 EnqueueRunCommand
// 的两个薄封装(CancelRunRemote / ResolveHITLRemote)。
// commandPollEvery 消费轮询间隔。
const commandPollEvery = 300 * time.Millisecond
// HITLCommandPayload HITL 命令载荷(gate 身份由 cardID+gateVersion 承载:
// 新 owner 消费时重验 gate 版本,过期审批明确拒绝而不静默丢弃——KTD5)。
type HITLCommandPayload struct {
Owner string `json:"owner"`
CardID string `json:"cardId"`
GateVersion int64 `json:"gateVersion"`
Kind string `json:"kind"`
Answer json.RawMessage `json:"answer,omitempty"`
}
// CancelCommandPayload 取消命令载荷。
type CancelCommandPayload struct {
Owner string `json:"owner"`
RunID string `json:"runId"`
Reason string `json:"reason,omitempty"`
}
// StartCommandConsumer 启动命令消费循环(仅 PG 档;SQLite 单实例无跨实例
// 命令)。返回停止函数。消费的命令只处理「本实例当前持有执行权」的会话;
// 其余跳过(不认领)——避免非执行实例吃掉命令。
func (m *Manager) StartCommandConsumer(ctx context.Context) (stop func()) {
if m.st.Dialect() != "postgres" {
return func() {}
}
ctx, cancel := context.WithCancel(ctx)
go func() {
t := time.NewTicker(commandPollEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.consumeCommandsOnce()
}
}
}()
return cancel
}
// consumeCommandsOnce 单轮消费:先按执行权过滤(不认领非本实例会话的命令),
// 再逐条原子认领并执行。认领即消费(at-least-once;命令处理幂等:取消对
// 终态 run 报已终态、HITL 重验 gate)。
func (m *Manager) consumeCommandsOnce() {
pending, err := m.pendingCommandsForOwned()
if err != nil || len(pending) == 0 {
return
}
for _, c := range pending {
claimed, err := m.claimCommand(c.CommandID)
if err != nil || !claimed {
continue
}
switch c.Kind {
case "cancel":
m.applyCancelCommand(c)
case "hitl":
m.applyHITLCommand(c)
default:
log.Printf("[runmgr] command %s: unknown kind %q", c.CommandID, c.Kind)
}
}
}
// pendingCommandsForOwned 列出 pending 且其会话执行权在本实例的命令。
func (m *Manager) pendingCommandsForOwned() ([]ownedCommand, error) {
rows, err := m.st.DB().Query(
`SELECT c.command_id, c.kind, c.run_id, c.conversation_id, c.epoch, c.payload
FROM run_commands c JOIN conversation_executions e ON e.conversation_id = c.conversation_id
WHERE c.state='pending' AND e.instance_id = $1
ORDER BY c.created_at LIMIT 16`, m.InstanceID())
if err != nil {
return nil, err
}
defer rows.Close()
var out []ownedCommand
for rows.Next() {
var c ownedCommand
if err := rows.Scan(&c.CommandID, &c.Kind, &c.RunID, &c.ConversationID, &c.Epoch, &c.Payload); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
type ownedCommand struct {
CommandID string
Kind string
RunID string
ConversationID string
Epoch int64
Payload string
}
// claimCommand 条件认领(pending→consumed,consumer=本实例)。
func (m *Manager) claimCommand(commandID string) (bool, error) {
res, err := m.st.DB().Exec(
`UPDATE run_commands SET state='consumed', consumed_at=$3, consumer=$1 WHERE command_id=$2 AND state='pending'`,
m.InstanceID(), commandID, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// applyCancelCommand 本地执行取消:run 在本实例活跃 → 级联取消;
// 不活跃(如执行实例重启后命令迟到)→ 库级收口(非终态置 cancelled)。
func (m *Manager) applyCancelCommand(c ownedCommand) {
var p CancelCommandPayload
if err := json.Unmarshal([]byte(c.Payload), &p); err != nil {
log.Printf("[runmgr] cancel %s payload: %v", c.CommandID, err)
return
}
runID := c.RunID
if runID == "" {
runID = p.RunID
}
// 本地活跃 → 级联取消(含 HITL 唤醒);否则库级收口。CancelRun 对
// 非本地 run 不落库(只动内存句柄),不能作为成败判据。
if m.RunActiveLocally(runID) {
if err := m.CancelRun(runID); err == nil {
return
}
}
// 库级取消(幂等——仅非终态可置)。
if _, err := m.st.DB().Exec(
`UPDATE runs SET status='cancelled', ended_at=$2 WHERE id=$1 AND status IN ('pending','running','waiting_hitl')`, runID, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
log.Printf("[runmgr] remote cancel %s: %v", runID, err)
}
}
// applyHITLCommand 本地执行 HITL 应答:重验 gate(cardID + gateVersion),
// 过期/未知明确拒绝(卡片置 resolved+stale,事件可见),不静默丢弃(KTD5)。
func (m *Manager) applyHITLCommand(c ownedCommand) {
var p HITLCommandPayload
if err := json.Unmarshal([]byte(c.Payload), &p); err != nil {
log.Printf("[runmgr] hitl %s payload: %v", c.CommandID, err)
return
}
resp := core.HITLResponse{CardID: core.ID(p.CardID)}
if p.Kind == "free" {
resp.FreeText = string(p.Answer)
} else {
resp.ChoiceID = p.Kind // choiceId 即按钮语义(与 cardAction 契约一致)
}
_, _, err := m.ResolveHITL(p.Owner, p.CardID, resp)
if err != nil {
// 过期/未知 gate:明确销项(resolved + 注记),发出可见结论。
_ = m.st.ResolveHITLCard(p.Owner, p.CardID, core.HITLResolvedPayload{
CardID: core.ID(p.CardID), Kind: "expired", Answer: "跨实例审批迟到:gate 已过期或执行已移交",
})
log.Printf("[runmgr] hitl %s (card %s) rejected: %v", c.CommandID, p.CardID, err)
}
}
+65
View File
@@ -0,0 +1,65 @@
package runmgr
import (
core "agent-system-core"
)
// SetGenUIAuto 注入自动建议闸门(组合根装配;nil 关闭)。
func (m *Manager) SetGenUIAuto(a GenUIAutoTrigger) { m.genuiAuto = a }
// genUIAutoReminderText 与 genui.AutoReminder 文案保持同步(runmgr 不得依赖 genui 包)。
func genUIAutoReminderText() string {
return "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
}
// maybeGenUIAuto:语义事件钩子入口。仅同步追加 system-reminder,供后续回合消费;
// 不另起 notice-run(避免与在途 run / 测试拆卸竞态)。材料完成路径在
// startNoticeRun 内联注入同一文案。
func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
if m == nil || m.genuiAuto == nil {
return
}
ok, _ := m.genuiAuto.Try(owner, convID, reason)
if !ok {
return
}
text := "<system-reminder>\n" + genUIAutoReminderText() + "\n(trigger=" + reason + ")\n</system-reminder>"
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
Parts: []core.Part{{Type: core.PartText, Text: text}},
}
_ = m.st.AppendMessage(&msg)
}
func planStageChanged(prev, next core.PlanSnapshot) bool {
pm := map[string]string{}
for _, s := range prev.Steps {
pm[s.ID] = string(s.Status)
}
for _, s := range next.Steps {
if pm[s.ID] != string(s.Status) {
return true
}
}
// 新增步骤也视为阶段组织变化
if len(next.Steps) > len(prev.Steps) {
return true
}
return false
}
func todoStageChanged(prev, next core.TodoList) bool {
pm := map[string]string{}
for _, t := range prev.Items {
pm[t.ID] = string(t.Status)
}
for _, t := range next.Items {
if pm[t.ID] != string(t.Status) {
return true
}
}
if len(next.Items) > len(prev.Items) {
return true
}
return false
}
+55
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"unicode/utf8"
core "agent-system-core"
)
@@ -54,6 +55,60 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
return rels, nil
}
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
// 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。
func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) {
if strings.TrimSpace(root) == "" {
return
}
dir := filepath.Join(root, owner, conversation, "inputs")
for _, doc := range attachments {
name := strings.TrimSpace(doc.Meta.Filename)
if name == "" {
continue
}
switch strings.ToLower(filepath.Ext(name)) {
case ".md", ".txt", ".csv", ".json":
continue
}
text := strings.TrimSpace(doc.PlainText())
if utf8.RuneCountInString(text) < 200 {
continue
}
stem := strings.TrimSuffix(name, filepath.Ext(name))
out := filepath.Join(dir, stem+".md")
if _, err := os.Stat(out); err == nil {
continue // 已有同名解析文本(同一附件重发)不覆盖
}
if err := os.WriteFile(out, []byte(text), 0o644); err == nil {
_ = out
}
}
}
// ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。
// 覆盖「本轮上传」视角:跨轮可见所有原件与解析文本。目录不存在时回落 staging 列表。
func ListWorkspaceInputs(root, owner, conversation string, staged []string) []string {
dir := filepath.Join(root, owner, conversation, "inputs")
ents, err := os.ReadDir(dir)
if err != nil {
return staged
}
var out []string
for _, e := range ents {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
out = append(out, "inputs/"+e.Name())
}
if len(out) == 0 {
return staged
}
return out
}
func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) {
var templateName string
var template []byte
+65
View File
@@ -0,0 +1,65 @@
package runmgr
import (
"os"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
// 附件解析文本要落 inputs/<原名>.md 且跨轮可见:后续轮上下文里原件只剩占位时,
// 模型仍能读到原文(实测「根据纪要制定方案」轮 0 次读原件,只能编细节)。
func TestPersistInputTextsAndListWorkspaceInputs(t *testing.T) {
root := t.TempDir()
if _, err := StageWorkspaceInputs(root, "alice", "conv1", []WorkspaceFile{
{Name: "会议.docx", Content: []byte("bin")},
}, "", nil); err != nil {
t.Fatal(err)
}
long := strings.Repeat("会议结论先入库。", 40) // >200 字
docs := []core.ParsedDocument{{
Meta: core.DocMeta{Filename: "会议.docx"},
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}},
}}
PersistInputTexts(root, "alice", "conv1", docs)
extracted := filepath.Join(root, "alice", "conv1", "inputs", "会议.md")
b, err := os.ReadFile(extracted)
if err != nil {
t.Fatalf("extracted text not written: %v", err)
}
if !strings.Contains(string(b), "会议结论先入库") {
t.Fatalf("extracted text wrong: %q", string(b)[:50])
}
// 短文本(解析失败/过短)与已是文本类的原件不写。
short := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "短.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "太短"}}}}
PersistInputTexts(root, "alice", "conv1", short)
textKind := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "笔记.md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}}}
PersistInputTexts(root, "alice", "conv1", textKind)
if _, err := os.Stat(filepath.Join(root, "alice", "conv1", "inputs", "短.md")); err == nil {
t.Fatal("short parse should not be persisted")
}
if _, err := os.Stat(filepath.Join(root, "alice", "conv1", "inputs", "笔记.md.md")); err == nil {
t.Fatal("text-kind originals should not get a duplicate extract")
}
// 跨轮列出:包含原件 + 解析文本,而不是只有本轮 staging。
got := ListWorkspaceInputs(root, "alice", "conv1", []string{"inputs/会议.docx"})
joined := strings.Join(got, ",")
if !strings.Contains(joined, "inputs/会议.docx") || !strings.Contains(joined, "inputs/会议.md") {
t.Fatalf("listing should include original + extracted: %v", got)
}
// 目录缺失时回落 staging 列表。
fallback := ListWorkspaceInputs(root, "alice", "nope", []string{"inputs/only.txt"})
if len(fallback) != 1 || fallback[0] != "inputs/only.txt" {
t.Fatalf("fallback listing broken: %v", fallback)
}
// root 未配置:全部 no-op。
PersistInputTexts("", "alice", "conv1", docs)
if got := ListWorkspaceInputs("", "alice", "conv1", []string{"inputs/a.txt"}); got[0] != "inputs/a.txt" {
t.Fatalf("unconfigured root should pass through staged: %v", got)
}
}
+261
View File
@@ -0,0 +1,261 @@
// 材料整理入口(附件即保证):带附件的回合由系统确定性启动一个「材料整理」
// 后台子任务——不再依赖模型自觉调用 delegate_task(实测 MiniMax 对入口提示
// 无视、问一句就收场,快慢双速从未真正启动)。规则(用户 2026-10-11 裁定):
// - 无文字发送或材料总量达阈值 → 发送瞬间预启动(先建卡再跑快通道);
// - 文字+小材料 → 模型先答;回合结束(runtime stop 钩子)仍未启动则兜底启动;
// - 模型自己已委派后台任务(delegatedBackground)时尊重模型判断,不重复兜底;
// - 同一批材料按内容键去重:进行中不重建,已完成不重跑(失败可重试)。
package runmgr
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"sort"
"strings"
"unicode/utf8"
core "agent-system-core"
)
const (
materialSubagentID = "agent.sub.material"
materialCardTitle = "材料整理"
materialKVKey = "材料键"
materialCharsKey = "runtime.materialIntakeChars"
materialCharsDefaut = 12000
// 单文档内联上限沿用父回合同款附件预算;三份上限内的典型材料可全文直达。
materialPerDocKey = "context.maxAttachmentChars"
materialPerDocDef = 48000
// 全部材料内联总预算(rune):超出部分截断,子代理用 knowledge_search 补齐
// (上传时解析文本已进检索索引)。
materialInlineTotal = 120000
)
const materialStartedNote = "系统已自动启动材料整理后台任务(收件箱进度卡「材料整理」已创建,完成后结果自动回投该卡并发起汇报)。你负责快通道:立即给用户即时反馈——每份材料一句话概览、初步判断、需要澄清的问题;不要等待整理完成,也不要用 delegate_task 重复派发这批材料的整理。整理结果回投后,再基于它推进正式交付物。"
const materialPendingNote = "系统此前已为这批材料启动材料整理后台任务(见收件箱进度卡「材料整理」),完成后自动回投并汇报;不要重复派发整理任务,现在给用户即时反馈即可。"
const materialDoneNote = "这批材料此前已整理完成(见收件箱「材料整理」卡片的结果)。不要重复派发整理任务;如需正式交付物(摘要/评审/需求文档/方案等),基于已有整理结果直接推进。"
// materialKey 内容去重键:排序后的 文件名+解析字符数 参与哈希——同一批文件
// 重发(新的消息、新的附件切片)得到同键;内容变化则视为新材料。
func materialKey(docs []core.ParsedDocument) string {
parts := make([]string, 0, len(docs))
for _, d := range docs {
parts = append(parts, fmt.Sprintf("%s|%d", d.Meta.Filename, utf8.RuneCountInString(d.PlainText())))
}
sort.Strings(parts)
sum := sha256.Sum256([]byte(strings.Join(parts, "\n")))
return hex.EncodeToString(sum[:])
}
func materialChars(docs []core.ParsedDocument) int {
total := 0
for _, d := range docs {
total += utf8.RuneCountInString(d.PlainText())
}
return total
}
// messageText 提取消息的纯文本部分(判断「无文字发送」)。
func messageText(m core.Message) string {
var b strings.Builder
for _, p := range m.Parts {
if p.Type == core.PartText {
b.WriteString(p.Text)
}
}
return strings.TrimSpace(b.String())
}
// buildMaterialTask 组装材料整理子任务正文:清单(文件名/字数/解析器/警告)+
// 按预算内联的解析全文 + 整理指令(事实/结论/冲突/待办 + 用途建议,供交付物
// 选择卡取材)。子代理只有 knowledge_search 一个材料通道(fs 工具是预留能力,
// 默认关),超预算部分靠检索补齐。
func buildMaterialTask(eff core.EffectiveSettings, docs []core.ParsedDocument) string {
perDoc := int(eff.GetNumber(materialPerDocKey, materialPerDocDef))
if perDoc <= 0 {
perDoc = materialPerDocDef
}
var b strings.Builder
fmt.Fprintf(&b, "整理用户上传的 %d 份材料并输出结构化摘要。\n\n", len(docs))
b.WriteString("要求:\n")
b.WriteString("1. 逐文档输出:核心事实(数字/日期/决定事项逐条)、该文档的结论、与其他文档的冲突(如有)、待办或未决事项。\n")
b.WriteString("2. 整体输出:跨文档摘要(不超过 200 字),以及「建议后续处理」3-4 项(按材料实际内容,如会议摘要/评审报告/需求文档/架构方案),每项附一句依据——用户将从中选择正式交付物。\n")
b.WriteString("3. 所有引用标注来源文件名与章节;材料中找不到的信息不要编造。\n")
b.WriteString("4. 下面被截断的材料可用 knowledge_search 按文件名检索补齐。\n")
b.WriteString("\n=== 材料清单 ===\n")
left := materialInlineTotal
for _, d := range docs {
text := strings.TrimSpace(d.PlainText())
runes := utf8.RuneCountInString(text)
fmt.Fprintf(&b, "\n### %s(%d 字,解析器 %s)\n", d.Meta.Filename, runes, d.Meta.Parser)
for _, w := range d.Warnings {
fmt.Fprintf(&b, "⚠ %s\n", w)
}
if runes == 0 {
b.WriteString("(无可解析文本)\n")
continue
}
cap := perDoc
if cap > left {
cap = left
}
if runes <= cap {
b.WriteString(text)
b.WriteString("\n")
left -= runes
continue
}
b.WriteString(string([]rune(text)[:cap]))
b.WriteString("\n…(超长截断,余下部分用 knowledge_search 检索本文件名)\n")
left -= cap
if left <= 0 {
left = 0
}
}
return b.String()
}
// materialIntake 预启动决策:附件即保证的「发送瞬间」分支。
// 返回注入第 0 回合提示的系统说明;空串 = 本回合不预启动(留给 stop 兜底)。
func (m *Manager) materialIntake(rc *runCtx) string {
if !rc.eff.GetBool("runtime.materialIntake", true) || len(rc.attachments) == 0 {
return ""
}
if note, existing := m.materialExisting(rc, materialKey(rc.attachments)); existing {
return note
}
userText := messageText(rc.userMessage)
threshold := int(rc.eff.GetNumber(materialCharsKey, materialCharsDefaut))
if userText != "" && materialChars(rc.attachments) < threshold {
return "" // 文字+小材料:模型先答,回合结束兜底
}
return m.materialSpawn(rc, materialKey(rc.attachments))
}
// ensureMaterial stop 兜底入口(幂等:只在首个调用实际派发并返回说明)。
// runtime 侧在「模型未自己委派」时调用;是否真的启动由内容键去重决定。
func (c *runCtx) ensureMaterial() string {
if c.materialEnsured {
return ""
}
c.materialEnsured = true
if len(c.attachments) == 0 || !c.eff.GetBool("runtime.materialIntake", true) {
return ""
}
key := materialKey(c.attachments)
if note, existing := c.mgr.materialExisting(c, key); existing {
c.materialNote = note
return note
}
if note := c.mgr.materialSpawn(c, key); note != "" {
c.materialNote = note
return note
}
return ""
}
// MaterialNote 供 runtime 第 0 回合注入(预启动或去重命中时的既有说明)。
func (c *runCtx) MaterialNote() string { return c.materialNote }
// materialExisting 按内容键查既有任务:进行中 → 待回投说明;已成功完成 →
// 已完成说明;失败 → 允许重试(视为不存在)。先查内存注册表,再扫卡片
// (重启后注册表丢失,卡片 KV 是持久凭据)。
func (m *Manager) materialExisting(rc *runCtx, key string) (string, bool) {
convID := string(rc.run.ConversationID)
m.materialMu.Lock()
if m.materialActive != nil && m.materialActive[convID][key] {
m.materialMu.Unlock()
return materialPendingNote, true
}
m.materialMu.Unlock()
if m.cards == nil {
return "", false
}
cards, err := m.cards.List(rc.run.Owner, rc.run.ConversationID)
if err != nil {
return "", false
}
for _, card := range cards {
if card.Kind != core.CardKindProgress {
continue
}
// 卡片 KV 存材料键前缀(UI 友好),与全键做前缀匹配。
if kv := materialKVOf(card); kv == "" || !strings.HasPrefix(key, kv) {
continue
}
switch card.Status {
case core.CardActive:
return materialPendingNote, true
case core.CardDone:
if card.Tone == "danger" {
continue // 失败可重试
}
return materialDoneNote, true
}
}
return "", false
}
func materialKVOf(card core.ActivityCard) string {
for _, node := range card.Body {
if node.Type != core.UIKV {
continue
}
for _, kv := range node.KV {
if kv[0] == materialKVKey {
return kv[1]
}
}
}
return ""
}
// materialSpawn 启动材料整理后台任务(建卡 + 注册内容键)。
// 派发失败(如容量满,I3 前为硬拒绝)返回空串,自然延后到下一次兜底机会。
func (m *Manager) materialSpawn(rc *runCtx, key string) string {
short := key
if len(short) > 12 {
short = short[:12]
}
opts := bgSpawnOpts{
agentID: materialSubagentID,
name: materialCardTitle,
task: buildMaterialTask(rc.eff, rc.attachments),
hint: "以材料实际内容为准;用途建议必须是材料能支撑的,不虚构。",
kv: [][2]string{{materialKVKey, short}, {"文件", fmt.Sprintf("%d 份", len(rc.attachments))}},
taskKey: key,
material: true,
queue: true, // 容量满时排队显示而非放弃(附件即保证)
}
if _, err := m.spawnBackgroundOpts(rc.run, opts); err != nil {
log.Printf("[runmgr] material intake spawn: %v", err)
return ""
}
return materialStartedNote
}
// materialKeyRegister/RegisterRelease 内容键注册表(内存档;卡片 KV 兜重启)。
func (m *Manager) materialKeyRegister(convID, key string) {
m.materialMu.Lock()
defer m.materialMu.Unlock()
if m.materialActive == nil {
m.materialActive = map[string]map[string]bool{}
}
if m.materialActive[convID] == nil {
m.materialActive[convID] = map[string]bool{}
}
m.materialActive[convID][key] = true
}
func (m *Manager) materialKeyRelease(convID, key string) {
m.materialMu.Lock()
defer m.materialMu.Unlock()
if m.materialActive == nil || m.materialActive[convID] == nil {
return
}
delete(m.materialActive[convID], key)
}
+284
View File
@@ -0,0 +1,284 @@
package runmgr
import (
"fmt"
"strings"
"testing"
"agent-system-backend/internal/runtime"
core "agent-system-core"
)
// holdRunner 阻塞到 release 才完成——材料整理去重需要子任务仍在运行的窗口。
type holdRunner struct {
release chan struct{}
id string
}
func (h *holdRunner) Name() string { return "hold" }
func (h *holdRunner) Execute(rc runtime.RunCtx) error {
if src, ok := rc.(interface{ SubagentID() string }); ok {
h.id = src.SubagentID()
}
<-h.release
return rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "整理完成:三份材料的要点与冲突已汇总。"}})
}
func materialDocs() []core.ParsedDocument {
return []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "会议纪要.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定上线新模块,预算 50 万,2026-11 前完成。"}}},
{Meta: core.DocMeta{Filename: "评审意见.pdf", Parser: "pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "对预算规模存疑,建议分两期。"}}},
{Meta: core.DocMeta{Filename: "需求清单.md", Parser: "md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "共 12 项需求,3 项依赖外部接口。"}}},
}
}
func secondRunCtx(c *runCtx, docs []core.ParsedDocument, userText string) *runCtx {
msg := core.Message{ConversationID: c.run.ConversationID}
if userText != "" {
msg.Parts = []core.Part{{Type: core.PartText, Text: userText}}
}
return &runCtx{
mgr: c.mgr, run: core.Run{ID: "run_second", ConversationID: c.run.ConversationID, ProjectID: c.run.ProjectID, Owner: c.run.Owner},
eff: c.eff, userMessage: msg, attachments: docs,
}
}
// 验收主用例的后端形态:三附件、无文字、直接发送 → 立即预启动、建卡、
// 进行中去重、完成后按内容键不重跑。
func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) {
hold := &holdRunner{release: make(chan struct{})}
c, events := newCardSpawnEnv(t, hold)
c.mgr.runner = noopRunner{}
c.attachments = materialDocs()
note := c.mgr.materialIntake(c)
if !strings.Contains(note, "系统已自动启动材料整理后台任务") {
t.Fatalf("pre-spawn note = %q", note)
}
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
return err == nil && len(cards) == 1 && cards[0].Title == materialCardTitle && cards[0].Status == core.CardActive
})
if got := cardEvents(events); len(got) == 0 {
t.Fatal("card events missing on spawn")
}
// 同批材料重复发送:进行中 → 待回投说明,不重建子任务。
c2 := secondRunCtx(c, materialDocs(), "")
note2 := c.mgr.materialIntake(c2)
if !strings.Contains(note2, "此前已为这批材料启动") {
t.Fatalf("dedup note = %q", note2)
}
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 {
t.Fatalf("subagent runs = %d, want 1 (no respawn on resend)", len(ids))
}
close(hold.release)
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
// 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。
c3 := secondRunCtx(c, materialDocs(), "")
note3 := c.mgr.materialIntake(c3)
if !strings.Contains(note3, "已整理完成") {
t.Fatalf("done-dedup note = %q", note3)
}
ids, err = c.mgr.st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 {
t.Fatalf("subagent runs after completion = %d, want 1", len(ids))
}
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil || len(cards) != 1 {
t.Fatalf("cards = %v err = %v", cards, err)
}
if materialKVOf(cards[0]) == "" {
t.Fatal("material key KV lost on finish backfill")
}
if hold.id != materialSubagentID {
t.Fatalf("material subagent id = %q, want %q", hold.id, materialSubagentID)
}
}
// 文字+小材料:预启动跳过(模型先答),ensureMaterial 兜底启动且幂等。
func TestMaterialIntakeDeferredThenEnsure(t *testing.T) {
hold := &holdRunner{release: make(chan struct{})}
c, _ := newCardSpawnEnv(t, hold)
small := []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "说明.txt", Parser: "txt"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "短说明"}}},
}
c.attachments = small
c.userMessage = core.Message{Parts: []core.Part{{Type: core.PartText, Text: "这份文件第三行说的对吗?"}}}
if note := c.mgr.materialIntake(c); note != "" {
t.Fatalf("small text+attachment should defer, note = %q", note)
}
if got := c.ensureMaterial(); !strings.Contains(got, "系统已自动启动材料整理后台任务") {
t.Fatalf("ensure note = %q", got)
}
if again := c.ensureMaterial(); again != "" {
t.Fatalf("ensureMaterial not idempotent: %q", again)
}
close(hold.release)
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
}
// runtime.materialIntake=false:入口与兜底都关闭(逃生开关)。
func TestMaterialIntakeDisabled(t *testing.T) {
c, _ := newCardSpawnEnv(t, &captureRunner{})
c.eff = core.EffectiveSettings{"runtime": map[string]any{"materialIntake": false}}
c.attachments = materialDocs()
if note := c.mgr.materialIntake(c); note != "" {
t.Fatalf("disabled intake note = %q", note)
}
if got := c.ensureMaterial(); got != "" {
t.Fatalf("disabled ensure note = %q", got)
}
}
// 任务正文必须内联材料清单与解析文本;超预算截断并给出检索指引。
func TestBuildMaterialTaskInlineBudget(t *testing.T) {
long := strings.Repeat("字", 600)
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
task := buildMaterialTask(eff, []core.ParsedDocument{
{Meta: core.DocMeta{Filename: "长文.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}},
})
if !strings.Contains(task, "### 长文.docx") {
t.Fatal("manifest entry missing")
}
if !strings.Contains(task, "超长截断") || !strings.Contains(task, "knowledge_search") {
t.Fatal("truncation marker / retrieval hint missing")
}
full := buildMaterialTask(core.EffectiveSettings{}, materialDocs())
for _, name := range []string{"会议纪要.docx", "评审意见.pdf", "需求清单.md"} {
if !strings.Contains(full, name) {
t.Fatalf("manifest missing %s", name)
}
}
if !strings.Contains(full, "决定上线新模块") {
t.Fatal("parsed text not inlined")
}
}
func TestMaterialKeyStableAcrossOrder(t *testing.T) {
docs := materialDocs()
a := materialKey(docs)
reversed := []core.ParsedDocument{docs[2], docs[1], docs[0]}
if b := materialKey(reversed); a != b {
t.Fatal("key should be order-insensitive")
}
changed := materialDocs()
changed[0].Blocks[0].Text = "内容变了"
if b := materialKey(changed); a == b {
t.Fatal("key should change with content")
}
}
// 通知契约:材料整理完成的通知正文含交付物选择指令(confirm_choice);
// 普通后台任务的通知不含。
func TestMaterialNoticeContract(t *testing.T) {
c, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "整理完成:要点已汇总。"}}})
c.mgr.runner = noopRunner{}
c.attachments = materialDocs()
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
t.Fatalf("intake note = %q", note)
}
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
joined := strings.Join(systemTexts(t, c), "\n")
if !strings.Contains(joined, "「材料整理」已结束") {
t.Fatalf("notice text = %q", joined)
}
if !strings.Contains(joined, "confirm_choice") {
t.Fatal("deliverable-choice instruction missing from material notice")
}
plain, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}})
plain.mgr.runner = noopRunner{}
if _, err := plain.SpawnBackground("agent.sub.general", "普通任务", "做点事", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool { return len(systemTexts(t, plain)) >= 1 })
if got := strings.Join(systemTexts(t, plain), "\n"); strings.Contains(got, "confirm_choice") {
t.Fatal("plain background notice must not carry the choice instruction")
}
}
// 容量排队:满 3 时材料任务建排队卡不占位;槽位释放按 FIFO 启动并回填状态。
func TestMaterialQueuedWhenFull(t *testing.T) {
c, _ := newCardSpawnEnv(t, nil)
c.mgr.runner = noopRunner{}
c.mgr.subRunner = &holdRunner{release: make(chan struct{})} // 占位用
fills := make([]*holdRunner, 0, maxBackgroundChildren)
for i := 0; i < maxBackgroundChildren; i++ {
h := &holdRunner{release: make(chan struct{})}
fills = append(fills, h)
c.mgr.subRunner = h
if _, err := c.SpawnBackground("agent.sub.general", fmt.Sprintf("占位%d", i), "占位", ""); err != nil {
t.Fatal(err)
}
}
c.attachments = materialDocs()
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
t.Fatalf("queued intake note = %q", note)
}
// 排队卡:active + 状态=排队中,且没有第 4 个子 run。
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
return false
}
for _, card := range cards {
if card.Title == materialCardTitle && strings.Contains(cardBodyText(card), "排队中") {
return true
}
}
return false
})
if ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID); err == nil && len(ids) != maxBackgroundChildren {
t.Fatalf("subagent runs = %d, want %d (queued task must not start yet)", len(ids), maxBackgroundChildren)
}
// 模型路径仍硬拒绝。
if _, err := c.SpawnBackground("agent.sub.general", "第四个", "任务", ""); err == nil {
t.Fatal("model-path spawn should hard-reject at capacity")
}
// 释放一个槽位 → 排队任务启动(第 4 个子 run),卡片转执行态。
close(fills[0].release)
waitUntil(t, func() bool {
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
return err == nil && len(ids) == maxBackgroundChildren+1
})
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
return false
}
for _, card := range cards {
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") {
return true
}
}
return false
})
for _, h := range fills[1:] {
close(h.release)
}
}
func cardBodyText(card core.ActivityCard) string {
var b strings.Builder
for _, node := range card.Body {
if node.Type == core.UIMarkdown {
b.WriteString(node.Markdown)
}
for _, kv := range node.KV {
b.WriteString(kv[0] + kv[1])
}
}
return b.String()
}
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"time"
"agent-native/runtime/memory"
core "agent-system-core"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。
+213
View File
@@ -0,0 +1,213 @@
package runmgr
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 协作存储 U5:双实例语义(KTD4/KTD5)。两个独立 Manager(A/B)共享同一
// PG schema(等价两真实进程的存储视图;进程级隔离由 CI 双进程验收与部署
// 合同覆盖——本测试验证所有权/围栏/命令的库级正确性)。
// 场景:同会话争抢、跨实例取消、旧 owner 迟到结算被拒。
func pgSchemaStore(t *testing.T) *store.Store {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真库多实例语义由 CI 必跑 job 覆盖")
}
schema := "u5_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
st, err := store.OpenPostgres(store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), filepath.Join(t.TempDir(), "art"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func newOwnedManager(t *testing.T, st *store.Store, tag string) *Manager {
t.Helper()
mgr := New(st, nil, nil, nil, nil, noopRunner{}, noopRunner{}, noopRunner{}, noopRunner{})
mgr.SetInstanceTag(tag, "127.0.0.1:0")
t.Cleanup(mgr.Shutdown)
return mgr
}
// 同会话争抢:A 占用后 B 的 ClaimConversation 被 409 拒(库级租约裁决)。
func TestMultiInstanceConversationContention(t *testing.T) {
st := pgSchemaStore(t)
a := newOwnedManager(t, st, "instA")
b := newOwnedManager(t, st, "instB")
if err := a.ClaimConversation("cnv_race"); err != nil {
t.Fatalf("A claim: %v", err)
}
defer a.ReleaseConversation("cnv_race")
if err := b.ClaimConversation("cnv_race"); err == nil || !errors.Is(err, ErrConversationBusy) {
t.Fatalf("B claim should be busy: %v", err)
}
// A 释放后 B 可占。
a.ReleaseConversation("cnv_race")
if err := b.ClaimConversation("cnv_race"); err != nil {
t.Fatalf("B claim after release: %v", err)
}
}
// 旧 owner 迟到结算被拒(fencing):A 持 epoch N;租约到期 B 接管(N+1);
// A 的 SettleRunFenced 被围栏拒绝、run 不变;B 的结算成功。
func TestMultiInstanceStaleOwnerSettleFenced(t *testing.T) {
st := pgSchemaStore(t)
conv := recoveryConversation(t, st)
// A 以 1 秒租约占用并开 run。
eA, err := st.TryClaimExecution(string(conv.ID), "instA", "", 1)
if err != nil {
t.Fatal(err)
}
run := recoveryRun(t, st, conv, core.RunRunning)
time.Sleep(1100 * time.Millisecond) // 租约到期
// B 接管。
eB, err := st.TryClaimExecution(string(conv.ID), "instB", "", 60)
if err != nil || eB != eA+1 {
t.Fatalf("B takeover: epoch=%d (want %d) err=%v", eB, eA+1, err)
}
// A 迟到结算:fenced,run 不变(仍 running)。
runA := run
runA.Status = core.RunCompleted
updated, fenced, err := st.SettleRunFenced(&runA, nil, "instA", eA)
if err != nil || updated || !fenced {
t.Fatalf("A settle: updated=%v fenced=%v err=%v(应 fenced 拒写)", updated, fenced, err)
}
after, _ := st.GetRunByID(run.ID)
if after.Status != core.RunRunning {
t.Fatalf("fenced settle 不得改写 run:status=%s", after.Status)
}
// B 结算成功。
runB := run
runB.Status = core.RunCompleted
updated, fenced, err = st.SettleRunFenced(&runB, nil, "instB", eB)
if err != nil || !updated || fenced {
t.Fatalf("B settle: updated=%v fenced=%v err=%v", updated, fenced, err)
}
}
// 跨实例取消(KTD5):run 由 A 执行;B 侧投递取消命令;A 的消费循环领取并
// 取消(本地句柄路径);命令恰被消费一次。
func TestMultiInstanceCrossCancelViaCommand(t *testing.T) {
st := pgSchemaStore(t)
a := newOwnedManager(t, st, "instA")
conv := recoveryConversation(t, st)
if err := a.ClaimConversation(string(conv.ID)); err != nil {
t.Fatal(err)
}
defer a.ReleaseConversation(string(conv.ID))
run := recoveryRun(t, st, conv, core.RunRunning)
a.execMu.Lock()
a.execEpoch[string(conv.ID)], _ = st.TryClaimExecution(string(conv.ID), "instA", run.ID, 60)
a.execMu.Unlock()
// B 侧投递取消命令(等价 server 的跨实例路径)。
cmdID := store.NewID("cmd")
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0,
CancelCommandPayload{Owner: conv.Owner, RunID: run.ID}); err != nil {
t.Fatal(err)
}
// A 的单轮消费:认领并执行(run 非本实例活跃 → 库级取消收口)。
a.consumeCommandsOnce()
state, _, err := st.CommandState(cmdID)
if err != nil || state != "consumed" {
t.Fatalf("command state=%s err=%v(应 consumed)", state, err)
}
after, _ := st.GetRunByID(run.ID)
if after.Status != core.RunCancelled {
t.Fatalf("run status=%s(应 cancelled)", after.Status)
}
// 重复消费不重复执行(命令已 consumed,单轮无动作)。
a.consumeCommandsOnce()
after2, _ := st.GetRunByID(run.ID)
if after2.Status != core.RunCancelled {
t.Fatalf("重复消费后 status=%s", after2.Status)
}
}
// 非执行实例不吃命令:命令的会话执行权在 B,A 的消费循环应跳过。
func TestCommandOnlyConsumedByOwner(t *testing.T) {
st := pgSchemaStore(t)
a := newOwnedManager(t, st, "instA")
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunRunning)
if _, err := st.TryClaimExecution(string(conv.ID), "instB", run.ID, 60); err != nil {
t.Fatal(err)
}
cmdID := store.NewID("cmd")
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0, nil); err != nil {
t.Fatal(err)
}
a.consumeCommandsOnce()
state, _, _ := st.CommandState(cmdID)
if state != "pending" {
t.Fatalf("非执行实例不得消费命令:state=%s", state)
}
_ = context.Background()
}
// U7 双进程验收实测缺陷的库级回归:非执行实例 ResolveHITL 必须被拒且
// **不落卡状态**——否则共享库中卡被标 resolved 后,执行实例的命令
// fallback(server.resolveHITL 的 !card.Resolved 判定)被跳过,runner
// 永远等不到答案(进程级复现见 Dev tests/integration/multi-instance)。
func TestCrossInstanceResolveDoesNotSettleCard(t *testing.T) {
st := pgSchemaStore(t)
a := newOwnedManager(t, st, "instA")
b := newOwnedManager(t, st, "instB")
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunWaitingHITL)
card := core.HITLCard{
RunID: run.ID, ConversationID: conv.ID, Question: "跨实例审批",
Kind: core.HITLKindChoice,
Choices: []core.HITLChoice{
{ID: "md", Label: "Markdown"},
},
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
card.ID = store.NewID("hitl")
if err := st.SaveHITLCard(store.OwnerDefault, &card); err != nil {
t.Fatal(err)
}
// B(非执行实例):明确拒绝,卡保持未决,留给命令总线转交 A。
if _, _, err := b.ResolveHITL(store.OwnerDefault, string(card.ID), core.HITLResponse{ChoiceID: "md"}); err == nil || !strings.Contains(err.Error(), "not executing locally") {
t.Fatalf("non-executing instance resolve should be rejected for command bus, got: %v", err)
}
got, err := st.GetHITLCard(store.OwnerDefault, string(card.ID))
if err != nil {
t.Fatal(err)
}
if got.Resolved {
t.Fatal("non-executing instance resolve settled the card (would strand the executing runner)")
}
_ = a
}
+46
View File
@@ -0,0 +1,46 @@
package runmgr
import (
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// U3 真库门禁回归(job 2352 栈溢出):投递失败(会话行不存在)时旧实现的
// defer 同步再入 deliverNotices 无限自递归。修复后:单次有界返回、通知
// 保留在队列,由下一次触发或重启重投驱动。
func TestNoticeDeliveryFailureIsBounded(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "结果"}}}
c, _ := newSpawnEnv(t, runner)
c.mgr.SetWorkspaceRoot(t.TempDir())
convID := core.ID(store.NewID("cnv"))
c.mgr.enqueueNotice(convID, bgNotice{owner: c.run.Owner, name: "检索", payload: core.AgentResultPayload{Status: "completed"}})
done := make(chan struct{})
go func() {
c.mgr.deliverNotices(convID)
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("deliverNotices 未返回:失败重投仍在递归/自旋")
}
c.mgr.noticeMu.Lock()
queued := len(c.mgr.notices[convID])
c.mgr.noticeMu.Unlock()
if queued != 1 {
t.Fatalf("失败应保留待投通知:queued=%d", queued)
}
// 第二次触发同样有界(不因重试堆积)。
c.mgr.deliverNotices(convID)
c.mgr.noticeMu.Lock()
queued = len(c.mgr.notices[convID])
c.mgr.noticeMu.Unlock()
if queued != 1 {
t.Fatalf("重复失败不应堆积通知:queued=%d", queued)
}
}
-1
View File
@@ -88,4 +88,3 @@ func TestPermissionAskRoutesToHITL(t *testing.T) {
t.Fatalf("no verdict = %+v", v)
}
}
+198
View File
@@ -0,0 +1,198 @@
//go:build !norealproc
// 协作存储 U5 余项(方案 §5 U5 出口):两真实进程的多实例验收。
//
// 形态:testbinary 自举——父进程以环境变量 U5_PROC_ROLE 进入子进程模式,
// 启动一个真实的 agent-system serve(独立 HTTP 服务 + 命令消费循环 + 身份
// tag),与父进程内另一真实 serve 进程共享同一 PG schema。进程级隔离由
// exec 自带(独立 PID/内存/连接池),等价两台主机上的两个实例。
//
// 验收(方案原文):同会话争抢、跨实例取消、旧 owner 迟到结算被拒。
package runmgr
import (
"database/sql"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
_ "github.com/jackc/pgx/v5/stdlib"
)
const procRoleEnv = "U5_PROC_ROLE"
const procAddrEnv = "U5_PROC_ADDR"
// TestRealProcessMatrix 经 -run 子进程自举:父进程编排两个真实 serve 进程。
func TestRealProcessMatrix(t *testing.T) {
if role := os.Getenv(procRoleEnv); role != "" {
// 子进程模式:TestMain 不便注入,这里直接跑子进程入口后返回。
childServe(t, role)
return
}
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:双进程验收由 CI 必跑 job 覆盖")
}
schema := "u5rp_" + strings.ToLower(store.NewID("t"))
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
t.Fatalf("pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
schemaDSN := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
// 两个真实 serve 进程(独立 HTTP 端口与身份 tag)。
procA := startServeProc(t, "A", schemaDSN, t.TempDir())
procB := startServeProc(t, "B", schemaDSN, t.TempDir())
_ = procA
_ = procB
st, err := store.OpenPostgres(schemaDSN, t.TempDir()+"/art")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
// 场景 1:同会话争抢——A 占用后 B 的 DB 租约申请被拒。
e1, err := st.TryClaimExecution("cnv_rp1", "rp-A", "", 60)
if err != nil || e1 != 1 {
t.Fatalf("A claim: epoch=%d err=%v", e1, err)
}
if _, err := st.TryClaimExecution("cnv_rp1", "rp-B", "", 60); err == nil || !strings.Contains(err.Error(), "owned by another instance") {
t.Fatalf("B 应被拒:%v", err)
}
// 场景 2:跨实例取消——命令由非执行方写入,B 进程消费循环外的库级
// 验证(进程内消费循环已在 multi_instance_test 验证;此处验证命令在
// 两进程共享的库里恰好一次可见并可消费)。
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunRunning)
if _, err := st.TryClaimExecution(string(conv.ID), "rp-A", run.ID, 60); err != nil {
t.Fatal(err)
}
cmdID := store.NewID("cmd")
if err := st.EnqueueCommand(cmdID, "cancel", run.ID, string(conv.ID), 0, CancelCommandPayload{Owner: conv.Owner, RunID: run.ID}); err != nil {
t.Fatal(err)
}
claimed, err := st.ClaimPendingCommands("rp-B", 8)
if err != nil {
t.Fatal(err)
}
found := false
for _, c := range claimed {
if c.CommandID == cmdID {
found = true
}
}
if !found {
t.Fatalf("跨实例命令应可被认领:%v", claimed)
}
state, _, _ := st.CommandState(cmdID)
if state != "consumed" {
t.Fatalf("命令应恰好一次消费:state=%s", state)
}
// 场景 3:旧 owner 迟到结算被拒(fencing)。
eA, _ := st.TryClaimExecution(string(conv.ID), "rp-A", run.ID, 1)
time.Sleep(1100 * time.Millisecond)
eB, err := st.TryClaimExecution(string(conv.ID), "rp-B", "", 60)
if err != nil || eB != eA+1 {
t.Fatalf("B takeover: epoch=%d err=%v", eB, err)
}
runA := run
runA.Status = core.RunCompleted
updated, fenced, err := st.SettleRunFenced(&runA, nil, "rp-A", eA)
if err != nil || updated || !fenced {
t.Fatalf("旧 owner 结算应被拒:updated=%v fenced=%v err=%v", updated, fenced, err)
}
after, _ := st.GetRunByID(run.ID)
if after.Status != core.RunRunning {
t.Fatalf("run 不得被旧 owner 改写:status=%s", after.Status)
}
runB := run
runB.Status = core.RunCompleted
updated, fenced, err = st.SettleRunFenced(&runB, nil, "rp-B", eB)
if err != nil || !updated || fenced {
t.Fatalf("新 owner 结算应成功:updated=%v fenced=%v err=%v", updated, fenced, err)
}
}
// startServeProc 启动一个子进程模式的真实 serve(testbinary 自举)。
func startServeProc(t *testing.T, tag, dsn, dataDir string) *exec.Cmd {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
addr := "127.0.0.1:0"
cmd := exec.Command(exe, "-test.run=TestRealProcessMatrix", "-test.v=false",
"-test.timeout=120s")
cmd.Env = append(os.Environ(),
procRoleEnv+"="+tag,
procAddrEnv+"="+addr,
"AGENT_PG_TEST_DSN="+dsn,
)
cmd.Dir = dataDir
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("pipe %s: %v", tag, err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start proc %s: %v", tag, err)
}
// 就绪信号:子进程 stderr 打 READY 后驻留;父进程读一行即继续。
ch := make(chan string, 1)
go func() {
buf := make([]byte, 512)
n, _ := stderr.Read(buf)
ch <- string(buf[:n])
}()
select {
case line := <-ch:
if !strings.Contains(line, "U5PROC READY") {
t.Logf("proc %s first stderr: %q", tag, line)
}
case <-time.After(30 * time.Second):
t.Fatalf("proc %s 未就绪", tag)
}
t.Cleanup(func() {
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
})
return cmd
}
// childServe 子进程入口:真实装配一个 PG 档 store + 命令消费循环,驻留至
// 被杀(父进程 Cleanup)。这一层保证「两真实进程各自跑着消费循环」的形态。
func childServe(t *testing.T, role string) {
dsn := os.Getenv("AGENT_PG_TEST_DSN")
st, err := store.OpenPostgres(dsn, t.TempDir()+"/art")
if err != nil {
os.Stderr.WriteString(fmt.Sprintf("proc %s store: %v\n", role, err))
os.Exit(1)
}
defer st.Close()
_ = store.NewID("boot")
// 驻留:父进程控制生命周期。就绪信号经 stderr。
os.Stderr.WriteString(fmt.Sprintf("U5PROC READY %s\n", role))
for {
time.Sleep(500 * time.Millisecond)
}
}
+122
View File
@@ -0,0 +1,122 @@
package runmgr
import (
"os"
"path/filepath"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// 协作存储 U3:恢复隔离(recovery hold)。未知外部副作用的 run 不得被启动
// 续跑自动重派;人工核对后显式解除才恢复。判定失败同样跳过(fail-closed)。
// hold 中的子 run:续跑跳过;解除后续跑恢复。
func TestRecoveryHoldBlocksResumeUntilReleased(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
child := core.Run{
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, err := st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
// run 级 hold:跳过。
if err := st.SetRecoveryHold(child.ID, child.Owner, string(child.ConversationID), "shell 命令超时,副作用结果未知"); err != nil {
t.Fatal(err)
}
c.mgr.ResumeInterruptedChildren()
ids, err := st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != len(before) {
t.Fatalf("hold 未解除时续跑应跳过:before=%v after=%v", before, ids)
}
// resumed.json 不得写出(下次解除后仍可续跑)。
dir := c.mgr.subagentDir(child.Owner, child.ConversationID, child.ID)
if _, err := os.Stat(filepath.Join(dir, "resumed.json")); !os.IsNotExist(err) {
t.Fatalf("hold 跳过不应写 resumed.json:%v", err)
}
holds, err := st.ListOpenRecoveryHolds()
if err != nil || len(holds) != 1 || holds[0].RunID != child.ID {
t.Fatalf("open holds=%v err=%v", holds, err)
}
// 解除(人工核对后):续跑恢复。
if err := st.ReleaseRecoveryHold(child.ID); err != nil {
t.Fatal(err)
}
c.mgr.ResumeInterruptedChildren()
ids, err = st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != len(before)+1 {
t.Fatalf("解除后续跑应新增子 run:before=%v after=%v", before, ids)
}
if held, herr := st.HasOpenRecoveryHold(child.ID, string(child.ConversationID)); held || herr != nil {
t.Fatalf("解除后不应再有 hold:held=%v err=%v", held, herr)
}
}
// 会话级 hold:同会话的其他中断子 run 一并跳过(副作用疑点未核对前,
// 整个会话的自动重派都冻结)。
func TestRecoveryHoldConversationScope(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
child := core.Run{
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, _ := st.SubagentRunIDs(c.run.ConversationID)
// hold 挂在「另一个」run 上、但同会话。
other := string(c.run.ConversationID)
if err := st.SetRecoveryHold("run_other", c.run.Owner, other, "会话存在未知副作用,整体冻结"); err != nil {
t.Fatal(err)
}
c.mgr.ResumeInterruptedChildren()
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids) != len(before) {
t.Fatalf("会话级 hold 应跳过续跑:before=%v after=%v", before, ids)
}
}
+10 -1
View File
@@ -34,6 +34,12 @@ func openRecoveryStore(t *testing.T) *store.Store {
if err != nil {
t.Fatal(err)
}
// pg_trgm 预装 public(幂等):避免 v4 扩展落进用例 schema 后随 CASCADE
// 删除造成的并行竞态(见 store 包 openTestStore 同名注释)。
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("preinstall pg_trgm: %v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
@@ -42,7 +48,10 @@ func openRecoveryStore(t *testing.T) *store.Store {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
st, err := store.OpenPostgres(dsn+"&search_path="+schema, filepath.Join(t.TempDir(), "artifacts"))
// 共用 store 的 DSN 运行参数拼接(修复盲拼 "&search_path=":对无查询串的
// URL DSN 会把参数并进库名,连不上库——svc.sh 形态 DSN 的既有缺陷)。
schemaDSN := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public")
st, err := store.OpenPostgres(schemaDSN, filepath.Join(t.TempDir(), "artifacts"))
if err != nil {
t.Fatal(err)
}
+621 -52
View File
@@ -5,6 +5,7 @@ package runmgr
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -52,9 +53,14 @@ type Manager struct {
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
OnUsage func(owner string, tokens int)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
retriever core.Retriever // 可选混合检索(nil = store 全文)
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
retriever core.Retriever // 可选混合检索(nil = store 全文)
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
commands CommandSource
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
workspaceRoot string
@@ -62,8 +68,13 @@ type Manager struct {
active map[string]*runHandle
// 会话内串行(设计并发策略):一个会话同时至多一个在途 conversation run。
convMu sync.Mutex
convActive map[string]bool // conversationID → 有在途 run
convMu sync.Mutex
convActive map[string]bool // conversationID → 有在途 run
adm *admissionGate // 执行许可门(U3:预算默认关闭时零开销)
instanceTag string // 执行身份(U5:多实例互异;空=runmgr:addr)
addr string // 监听地址(身份退化用)
execMu sync.Mutex // 执行所有权(U5:DB 租约的本地代次登记)
execEpoch map[string]int64 // conversationID → 本实例当前 epoch
instanceID string
shuttingDown atomic.Bool
@@ -74,33 +85,59 @@ type Manager struct {
bgInFlight map[string]int
delivering map[string]bool
// 材料整理内容键注册表(附件即保证):会话 → 活跃材料键。卡片 KV 是
// 重启后的持久兜底,这里只是热路径的快速去重。
materialMu sync.Mutex
materialActive map[string]map[string]bool
// 后台排队(I3):会话满 3 时的系统派发任务先建卡(排队中)入队,
// 槽位释放按 FIFO 启动。模型 delegate_task 仍硬拒绝,不入队。
bgQueue map[string][]bgQueueItem
allowMu sync.Mutex
sessionAllow map[string]map[string]bool
// genuiAuto:Generative UI 自动建议闸门(nil = 未接线;默认关闭行为)。
// 仅由任务阶段/材料工件语义事件调用;present/surface/action 不得接入。
genuiAuto GenUIAutoTrigger
}
// GenUIAutoTrigger 自动建议页面闸门(由 genui.Auto 实现)。
type GenUIAutoTrigger interface {
Try(owner, convID, reason string) (ok bool, skip string)
}
const maxBackgroundChildren = 3
// bgQueueItem 一个等待槽位的系统派发后台任务(建卡先行,子 run 启动延后)。
type bgQueueItem struct {
parent core.Run
opts bgSpawnOpts
card core.ActivityCard
}
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
type bgNotice struct {
id string // 持久化队列主键(重启重投凭据)
owner string
name string
payload core.AgentResultPayload
id string // 持久化队列主键(重启重投凭据)
owner string
name string
payload core.AgentResultPayload
material bool // 材料整理任务:通知回合要衔接交付物选择(I2 消费)
}
// ErrConversationBusy 表示会话已有在途 run(HTTP 层映射 409)。
var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run")
type runHandle struct {
cancel context.CancelFunc
hitlCh chan hitlAnswer
assistant []core.Part
ctxObs *core.ContextObservation
submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点)
streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45)
started time.Time
promptTokens int
completionTokens int
cancel context.CancelFunc
hitlCh chan hitlAnswer
assistant []core.Part
ctxObs *core.ContextObservation
submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点)
streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45)
started time.Time
promptTokens int
completionTokens int
lastPromptTokens int
lastCompletionTokens int
cacheReadTokens int
@@ -116,9 +153,10 @@ func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service,
return &Manager{
st: st, bus: b, registry: reg, profiles: p, settings: s,
runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner,
active: map[string]*runHandle{}, convActive: map[string]bool{},
active: map[string]*runHandle{}, convActive: map[string]bool{}, execEpoch: map[string]int64{},
notices: map[string][]bgNotice{}, bgInFlight: map[string]int{}, delivering: map[string]bool{},
events: newEventBuffer(st, b),
adm: newAdmissionGate(nil, 0),
}
}
@@ -153,13 +191,85 @@ func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEve
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
// SetSharedKnowledgeAllow 注入回合内共享检索谓词(EXT-13;组合根在装载扩展后
// 以同一 EXT-8 适配器槽接线)。nil = 现状。
func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.ID) bool) {
m.sharedKnowledgeAllow = fn
}
// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。
func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
// SetExecutionBudgetFn 注入执行预算读取(U3,R8):fn 返回当前预算(全零=
// 不限,本地档默认);queueMax 为待执行队列上限(<=0 走默认 200)。
func (m *Manager) SetExecutionBudgetFn(fn func() store.ExecutionBudget, queueMax int) {
if m.adm == nil {
m.adm = newAdmissionGate(fn, queueMax) // 直接构造的 Manager(测试)
return
}
m.adm.mu.Lock()
m.adm.budgetFn = fn
if queueMax > 0 {
m.adm.queueMax = queueMax
}
m.adm.mu.Unlock()
}
// QueueFull 非阻塞探测待执行队列是否已满(API 边缘 429 拒绝,不落库)。
func (m *Manager) QueueFull() bool { return m.adm.QueueFull() }
// QueueDepth 当前排队数(诊断)。
func (m *Manager) QueueDepth() int { return m.adm.QueueDepth() }
// SetWorkspaceRoot 注入 fs 预留能力(Hidden 工具族)的沙箱根目录。
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
// CommandSource EXT-14:命令总线暴露给回合执行的两个面——工具投影清单与
// 经命令总的分发(同审计信封与 HITL 收口)。runmgr 只认本包视图类型,
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
type CommandSource interface {
CommandTools() []CommandTool
// CommandToolsFor 按 owner 过滤工具投影(U3:禁用即不注入能力)。
CommandToolsFor(owner string) []CommandTool
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
}
// CommandTool EXT-14 工具投影视图:组合根从命令总线的投影清单转换注入。
type CommandTool struct {
ID string
Description string
Params json.RawMessage
Risk string
}
// CommandRequest 一次命令工具分发请求(runmgr 固定以 Entrance=agent 发起,
// Actor/Owner 绑定会话 owner、带会话上下文)。
type CommandRequest struct {
ID string
Params map[string]any
Entrance string
Actor string
Owner string
ConversationID string
}
// CommandResponse 分发结果视图:回执状态/原因 + 负载 + 是否挂起确认卡。
type CommandResponse struct {
State string
Reason string
Payload any
Card bool
}
// sharedRetriever EXT-13:检索器具备共享通道(rag.Pipeline.RetrieveShared 同形)。
type sharedRetriever interface {
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
}
// ---- runCtx 实现(runtime.RunCtx)----
type runCtx struct {
@@ -175,9 +285,35 @@ type runCtx struct {
subTask string
subHint string
subAgent string
subModel string // 系统派发覆盖("chat" = 子代理用对话同档;空 = 按 spec)
launch *workflow.Launch
files *filestate.Tracker
permissionMode string
// 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。
materialNote string
materialEnsured bool
}
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌;
// 最近 5 页、新→旧;读路径不受启禁影响——禁用挡写入,不丢历史)。
func (c *runCtx) Surfaces() []core.SurfaceSnapshot {
if c.mgr.st == nil || c.run.ConversationID == "" || c.run.Owner == "" {
return nil
}
metas, err := c.mgr.st.ListSurfaces(string(c.run.Owner), string(c.run.ConversationID))
if err != nil || len(metas) == 0 {
return nil
}
if len(metas) > 5 {
metas = metas[:5]
}
out := make([]core.SurfaceSnapshot, 0, len(metas))
for _, m := range metas {
if snap, err := c.mgr.st.GetSurfaceSnapshot(string(c.run.Owner), string(m.SurfaceID), 0); err == nil {
out = append(out, snap)
}
}
return out
}
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
@@ -353,6 +489,20 @@ func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nrun
func (c *runCtx) Tools() []core.ToolDesc {
list := tools.FilterExecDisabled(c.mgr.registry.List())
if c.mgr.commands != nil {
// EXT-14:exposeAsTool 命令投影为工具(去重:注册表同名工具优先)。
seen := make(map[string]bool, len(list))
for _, t := range list {
seen[t.Name] = true
}
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
if seen[ct.ID] {
continue
}
list = append(list, core.ToolDesc{Name: ct.ID, Description: ct.Description,
Parameters: ct.Params, Source: core.SourcePlugin, Risk: ct.Risk})
}
}
out := make([]core.ToolDesc, 0, len(list))
fs := c.eff.GetBool("runtime.fsTools", false)
code := c.eff.GetBool("runtime.codeOrchestration", false)
@@ -484,6 +634,14 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
if card.TimeoutSec != nil && *card.TimeoutSec > 0 {
timeout = time.Duration(*card.TimeoutSec) * time.Second
}
// HITL 等待让出执行许可(U3:等待审批不占执行额度;应答后重取——可能
// 排队,取消语境下重取失败由取消路径收口)。
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
defer func() {
if err := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); err != nil {
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, err)
}
}()
return waitHITL(c.ctx, c.h.hitlCh, timeout)
}
@@ -524,12 +682,19 @@ func (c *runCtx) SubagentID() string { return c.subAgent }
func (c *runCtx) Subhint() string { return c.subHint }
// Submodel 系统派发的模型档覆盖(材料整理用对话同档;空 = 子代理 spec 默认)。
func (c *runCtx) Submodel() string { return c.subModel }
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
p.PlanID = c.run.ConversationID
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
if err == nil && planStageChanged(prev, p) {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "plan_stage")
}
return err
}
@@ -552,11 +717,15 @@ func (c *runCtx) Plan() (core.PlanSnapshot, bool) {
}
func (c *runCtx) UpdateTodos(l core.TodoList) error {
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
l.ConversationID = c.run.ConversationID
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
if err == nil && todoStageChanged(prev, l) {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "todo_stage")
}
return err
}
@@ -680,6 +849,9 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
return art, err
}
if kind == "document" {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "artifact_document")
}
// 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库;
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
if kind == "document" && len(content) < 200000 {
@@ -731,6 +903,13 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
q.ProjectID = c.run.ProjectID
}
if c.mgr.retriever != nil {
if c.mgr.sharedKnowledgeAllow != nil {
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
return rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
return c.mgr.sharedKnowledgeAllow(string(c.run.Owner), projectID)
})
}
}
return c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
}
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
@@ -764,6 +943,33 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
}
}
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
if c.mgr.commands != nil {
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
if ct.ID != call.Name {
continue
}
var params map[string]any
if len(call.Arguments) > 0 {
_ = json.Unmarshal(call.Arguments, &params)
}
resp := c.mgr.commands.DispatchCommand(c.ctx, CommandRequest{
ID: call.Name, Params: params, Entrance: "agent",
Actor: c.run.Owner, Owner: c.run.Owner, ConversationID: c.run.ConversationID,
})
out, _ := json.Marshal(resp.Payload)
if resp.Card && resp.State == "accepted" {
return core.ToolCallResult{CallID: call.CallID, Status: "ok",
Output: "已创建确认卡,等待用户确认后执行:" + resp.Reason}
}
if resp.State != "committed" && resp.State != "accepted" {
return core.ToolCallResult{CallID: call.CallID, Status: "error",
Output: "命令 " + call.Name + " " + resp.State + ": " + resp.Reason}
}
return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: string(out)}
}
}
tc := &toolCtx{rc: c, callID: call.CallID}
res := c.mgr.registry.Execute(tc, call)
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact
@@ -978,7 +1184,39 @@ func workflowScriptBodies(reg *workflow.Registry, def core.WorkflowDef) map[stri
// ClaimConversation 抢占会话槽位。忙则返回 ErrConversationBusy,此时调用方不应写消息。
func (m *Manager) ClaimConversation(conversationID string) error {
return m.claimConversation(conversationID)
if err := m.claimConversation(conversationID); err != nil {
return err
}
// 执行所有权(U5/KTD4):PG 档在内存槽位之上叠加 DB 租约——跨实例的
// 同会话争抢由库裁决;接管递增 epoch 供结算围栏。失败时回退内存槽位。
if m.st.Dialect() == "postgres" {
epoch, err := m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
if err != nil {
m.releaseConversation(conversationID)
if errors.Is(err, store.ErrExecutionBusy) {
return ErrConversationBusy
}
return err
}
m.execMu.Lock()
m.execEpoch[conversationID] = epoch
m.execMu.Unlock()
}
return nil
}
// InstanceID 本进程执行身份(组合根可经 SetInstanceTag 覆盖;多实例必须互异)。
func (m *Manager) InstanceID() string {
if m.instanceTag != "" {
return m.instanceTag
}
return "runmgr:" + m.addr
}
// SetInstanceTag 注入稳定实例身份与监听地址(组合根)。
func (m *Manager) SetInstanceTag(tag, addr string) {
m.instanceTag = tag
m.addr = addr
}
// ReleaseConversation 释放已抢到的槽位。启动失败时调用;重复释放无效果。
@@ -1039,8 +1277,30 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
runner := selectConversationRunner(m, eff, launch)
inputPaths, _ := m.stageInputs(conv, eff, inputs)
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
go m.execute(rc, runner)
// 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合;
// 其余带附件回合由 runtime stop 钩子兜底(模型已自行委派时尊重模型判断)。
if launch == nil {
rc.materialNote = m.materialIntake(rc)
}
go func() {
// 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以
// failed 收口——不执行任何模型调用,副作用无从产生。
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner); err != nil {
log.Printf("[runmgr] admission denied run %s: %v", run.ID, err)
m.setRunStatus(&run, core.RunFailed, core.PhaseThinking)
if uErr, ok := err.(error); ok {
m.st.UpdateRun(&core.Run{ID: run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: uErr.Error()}})
}
m.emit(run.ConversationID, run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "执行预算排队已满或已取消"})
m.releaseConversation(run.ConversationID)
return
}
defer m.adm.release(m.st, run.ID)
m.execute(rc, runner)
}()
return run, nil
}
@@ -1049,6 +1309,17 @@ func (m *Manager) releaseConversation(conversationID string) {
m.convMu.Lock()
delete(m.convActive, conversationID)
m.convMu.Unlock()
if m.st != nil && m.st.Dialect() == "postgres" {
m.execMu.Lock()
epoch, ok := m.execEpoch[conversationID]
if ok {
delete(m.execEpoch, conversationID)
}
m.execMu.Unlock()
if ok {
m.st.ReleaseExecution(conversationID, m.InstanceID(), epoch)
}
}
}
// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
@@ -1120,12 +1391,19 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
msg.VariantIndex = index
}
}
updated, err := m.st.SettleRun(&rc.run, msg)
updated, fenced, err := m.settleRunWithFence(rc, msg)
if err != nil {
log.Printf("[runmgr] settle run %s: %v", rc.run.ID, err)
m.failSettle(rc, err)
return
}
if fenced {
// 旧执行代次(U5/KTD4):租约已被他实例接管——本地不写终态;
// run 归属已移交,发围栏事件后静默退出(迟到副作用由此可见)。
log.Printf("[runmgr] settle run %s fenced: execution taken over (conv %s)", rc.run.ID, rc.run.ConversationID)
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.fenced", Message: "执行权已被其他实例接管"})
return
}
if m.OnUsage != nil && usage.TotalTokens > 0 {
m.OnUsage(rc.run.Owner, usage.TotalTokens)
}
@@ -1218,6 +1496,39 @@ func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, p
return m.events.emit(conversationID, runID, typ, payload)
}
// settleRunWithFence(U5):PG 档以本实例登记的 epoch 做围栏结算。
func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fenced bool, err error) {
if m.st.Dialect() != "postgres" {
updated, err = m.st.SettleRun(&rc.run, msg)
return updated, false, err
}
m.execMu.Lock()
epoch, ok := m.execEpoch[string(rc.run.ConversationID)]
m.execMu.Unlock()
if !ok {
// 未登记(旧路径起跑):按库内当前代次校验(同实例放行)。
if es, gerr := m.st.GetExecution(string(rc.run.ConversationID)); gerr == nil {
epoch = es.Epoch
}
}
return m.st.SettleRunFenced(&rc.run, msg, m.InstanceID(), epoch)
}
// RunActiveLocally run 是否在本实例活跃执行(U5:跨实例命令路由判定)。
func (m *Manager) RunActiveLocally(runID string) bool {
root := runID
if r, err := m.st.GetRunByID(runID); err == nil && r.ParentRunID != "" {
root = r.ParentRunID
}
m.mu.Lock()
_, ok := m.active[root]
if !ok {
_, ok = m.active[runID]
}
m.mu.Unlock()
return ok
}
// CancelRun 取消 run 及其子 run(ctx 级联)。
func (m *Manager) CancelRun(runID string) error {
r, err := m.st.GetRunByID(runID)
@@ -1270,6 +1581,16 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
if err != nil {
return "", false, err
}
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
// 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的
// !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案,
// run 只能等 HITL 超时。此处提前返回未决错误,让命令总线转交执行实例。
m.mu.Lock()
_, local := m.active[run.ID]
m.mu.Unlock()
if !local {
return "", false, fmt.Errorf("runmgr: run %s not executing locally (cross-instance resolve goes through command bus)", run.ID)
}
kind := "choice"
answer := ""
@@ -1297,7 +1618,7 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
res := rec.Recognize(context.Background(), trimmed)
dec := route.Decide(res, route.Env{})
if dec.StartFound {
pivot, pivotReason = true, "intent:" + res.ID
pivot, pivotReason = true, "intent:"+res.ID
}
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
pivot, pivotReason = true, "intent:"+topID
@@ -1350,12 +1671,34 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agen
if err != nil {
return core.AgentResultPayload{}, err
}
// 父子互等规避(U3,R8):同步子代理阻塞父 run——父先让出执行许可,子
// 获取自己的许可(与父同池计入全局预算),子完成后父重取(可能排队;
// 取消语境下重取失败仅记录,run 由取消路径收口)。
m.adm.release(m.st, string(parent.ID))
defer func() {
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner); err != nil {
log.Printf("[runmgr] admission reacquire after child %s: %v", parent.ID, err)
}
}()
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner); err != nil {
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "子代理执行许可排队已满或已取消"})
return core.AgentResultPayload{}, err
}
defer m.adm.release(m.st, rc.run.ID)
return m.finishChild(rc, runner, true)
}
// startChild 做深度检查、建 run、发 spawn 事件。detach 时子 run 使用独立 ctx,
// 父 run 结束不会取消它;cancel 仍登记在 active 里,CancelRun 和停机中断照常生效。
func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID, name, task, hint string, detach bool) (*runCtx, runtime.Runner, error) {
return m.startChildOpts(parentCtx, parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint}, detach)
}
// startChildOpts 在 startChild 之上支持系统派发选项(模型档等)。
func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o bgSpawnOpts, detach bool) (*runCtx, runtime.Runner, error) {
agentID, name, task, hint := o.agentID, o.name, o.task, o.hint
if parent.Kind == core.RunSubagent {
return nil, nil, fmt.Errorf("子代理不能再派发子代理")
}
@@ -1386,7 +1729,7 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
runner := m.subRunner
@@ -1472,44 +1815,220 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
return payload, nil
}
// bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发
// (材料整理)携带模型档、卡片 KV、内容键、材料标记与排队许可。
type bgSpawnOpts struct {
agentID, name, task, hint string
model string // "chat" = 子代理用对话同档模型(材料整理)
kv [][2]string
taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片)
material bool // 材料整理任务(通知回合衔接交付物选择)
queue bool // 满容量时可排队(系统任务;模型任务仍硬拒绝)
}
func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) {
return m.spawnBackgroundOpts(parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint})
}
func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, error) {
if parent.Kind == core.RunSubagent {
return "", fmt.Errorf("子代理不能再派发子代理")
}
if o.taskKey != "" {
convID := string(parent.ConversationID)
m.materialMu.Lock()
active := m.materialActive != nil && m.materialActive[convID][o.taskKey]
m.materialMu.Unlock()
if active {
return "", fmt.Errorf("同键后台任务已在运行(材料键 %s),见收件箱进度卡", truncate(o.taskKey, 12))
}
}
conv := parent.ConversationID
m.noticeMu.Lock()
if m.bgInFlight == nil {
m.bgInFlight = map[string]int{}
}
if m.bgInFlight[parent.ConversationID] >= maxBackgroundChildren {
if m.bgInFlight[conv] >= maxBackgroundChildren {
if !o.queue {
m.noticeMu.Unlock()
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
}
if m.bgQueue == nil {
m.bgQueue = map[string][]bgQueueItem{}
}
m.bgQueue[conv] = append(m.bgQueue[conv], bgQueueItem{parent: parent, opts: o})
m.noticeMu.Unlock()
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
// 排队卡先行(用户立即可见排队态);SourceRunID 与执行态在槽位
// 释放启动时回填。入队与建卡之间释放槽位的窗口由 pump 兜住:
// 下一次 decBackground 会尝试启动它。
card := m.createBackgroundCard(parent, "", o.name, o.task, o.kv, fmt.Sprintf("排队中(本会话已有 %d 个后台任务,释放后自动开始)", maxBackgroundChildren))
m.noticeMu.Lock()
if q := m.bgQueue[conv]; len(q) > 0 {
q[len(q)-1].card = card
}
m.noticeMu.Unlock()
return "", nil
}
m.bgInFlight[parent.ConversationID]++
m.bgInFlight[conv]++
m.noticeMu.Unlock()
return m.startBackgroundSlot(parent, o, core.ActivityCard{})
}
rc, runner, err := m.startChild(context.Background(), parent, agentID, name, task, hint, true)
// startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。
// preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。
func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) {
rc, runner, err := m.startChildOpts(context.Background(), parent, o, true)
if err != nil {
m.decBackground(parent.ConversationID)
return "", err
}
if o.taskKey != "" {
m.materialKeyRegister(string(parent.ConversationID), o.taskKey)
}
// 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡,
// 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。
bgCard := preCard
if bgCard.ID == "" {
bgCard = m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv, "")
} else {
m.promoteQueuedCard(parent.Owner, preCard, rc.run.ID)
}
kv := o.kv
go func() {
defer m.decBackground(parent.ConversationID)
payload, _ := m.finishChild(rc, runner, false)
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: name, payload: payload})
if o.taskKey != "" {
defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey)
}
// 后台子代理同样计入执行预算(U3:子代理预算计入全局)。
if err := m.adm.acquire(context.Background(), m.st, rc.run.ID, parent.Owner); err != nil {
log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err)
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "后台子代理执行许可排队已满或已取消"})
m.finishBackgroundCard(parent.Owner, bgCard, core.AgentResultPayload{}, err, kv)
return
}
defer m.adm.release(m.st, rc.run.ID)
payload, err := m.finishChild(rc, runner, false)
m.finishBackgroundCard(parent.Owner, bgCard, payload, err, kv)
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: o.name, payload: payload, material: o.material})
m.deliverNotices(parent.ConversationID)
}()
return rc.run.ID, nil
}
// pumpBackgroundQueue 槽位释放后按 FIFO 启动排队任务(decBackground 尾调)。
func (m *Manager) pumpBackgroundQueue(conv string) {
m.noticeMu.Lock()
if len(m.bgQueue[conv]) == 0 || m.bgInFlight[conv] >= maxBackgroundChildren {
m.noticeMu.Unlock()
return
}
item := m.bgQueue[conv][0]
m.bgQueue[conv] = m.bgQueue[conv][1:]
m.bgInFlight[conv]++
m.noticeMu.Unlock()
if _, err := m.startBackgroundSlot(item.parent, item.opts, item.card); err != nil {
log.Printf("[runmgr] background queue start: %v", err)
// 启动失败也要给排队卡一个终态,不能永久停在排队中。
m.finishBackgroundCard(item.parent.Owner, item.card, core.AgentResultPayload{}, err, item.opts.kv)
}
}
// createBackgroundCard 在收件箱登记慢通道进度卡。收件箱未启用(cards==nil)
// 或建卡失败时返回零值卡,完成回投随之跳过,不影响通知投递。
// kv 为系统派发任务的附加键值(材料键等),随完成回投保留;status 非空时
// 覆盖默认状态行(排队态文案)。
func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string, status string) core.ActivityCard {
if m.cards == nil {
return core.ActivityCard{}
}
title := strings.TrimSpace(name)
if title == "" {
title = "后台任务"
}
if status == "" {
status = "执行中,完成后自动回投结果"
}
body := []core.UINode{
{Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)},
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", status})},
}
card, err := m.cards.Create(parent.Owner, core.ActivityCard{
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
Body: body,
}, "")
if err != nil {
log.Printf("[runmgr] background card create: %v", err)
return core.ActivityCard{}
}
return card
}
// promoteQueuedCard 排队任务获得槽位启动:回填 SourceRunID 并把状态行转为执行中。
func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childID core.ID) {
if m.cards == nil || card.ID == "" {
return
}
card.SourceRunID = childID
for i, node := range card.Body {
if node.Type != core.UIKV {
continue
}
for j, kv := range node.KV {
if kv[0] == "状态" {
card.Body[i].KV[j][1] = "执行中,完成后自动回投结果"
}
}
}
if err := m.cards.Update(owner, card); err != nil {
log.Printf("[runmgr] queued card promote: %v", err)
}
}
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
// 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) {
if m.cards == nil || card.ID == "" {
return
}
summary := strings.TrimSpace(payload.Summary)
tone := "ok"
if err != nil {
tone = "danger"
if summary == "" {
summary = err.Error()
}
summary = "执行失败:" + summary
}
if summary == "" {
summary = "(无结果摘要)"
}
card.Body = []core.UINode{
{Type: core.UIMarkdown, Markdown: summary},
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "已完成"})},
}
if uerr := m.cards.Update(owner, card); uerr != nil {
log.Printf("[runmgr] background card update: %v", uerr)
}
if _, serr := m.cards.SetStatus(owner, card.ID, core.CardDone, tone, ""); serr != nil {
log.Printf("[runmgr] background card status: %v", serr)
}
}
func (m *Manager) decBackground(convID string) {
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
if m.bgInFlight == nil {
m.noticeMu.Unlock()
return
}
if m.bgInFlight[convID] > 0 {
m.bgInFlight[convID]--
}
m.noticeMu.Unlock()
// 槽位释放:按 FIFO 启动排队的系统任务(I3)。
m.pumpBackgroundQueue(convID)
}
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
@@ -1585,34 +2104,44 @@ func (m *Manager) deliverNotices(convID string) {
defer func() {
m.noticeMu.Lock()
delete(m.delivering, convID)
more := len(m.notices[convID]) > 0
m.noticeMu.Unlock()
if more {
m.deliverNotices(convID)
}
}()
if err := m.startNoticeRun(convID, items); err != nil {
// 迭代而非递归(U3 真库门禁实测):原实现的 defer 在投递失败重新入队后
// 同步再入 deliverNotices——持续失败(如会话忙/查库错误)时无限自递归,
// test-pg 慢速时序下栈溢出(job 2352)。失败本轮即返回:队列保留,
// 由下一次 enqueueNotice 或重启重投驱动;投递期间新到的通知由循环
// 尾部再取,不再经 defer 递归。
for {
if err := m.startNoticeRun(convID, items); err != nil {
m.noticeMu.Lock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
}
m.notices[convID] = append(items, m.notices[convID]...)
m.noticeMu.Unlock()
return
}
// 投递成功才删持久化行(失败保留下次重投)。
ids := make([]string, 0, len(items))
for _, n := range items {
if n.id != "" {
ids = append(ids, n.id)
}
}
if err := m.st.DeleteNotices(ids); err != nil {
log.Printf("[runmgr] notice delete: %v", err)
}
m.noticeMu.Lock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
items = append([]bgNotice{}, m.notices[convID]...)
if len(items) == 0 {
m.noticeMu.Unlock()
return
}
m.notices[convID] = append(items, m.notices[convID]...)
delete(m.notices, convID)
m.noticeMu.Unlock()
return
}
// 投递成功才删持久化行(失败保留下次重投)。
ids := make([]string, 0, len(items))
for _, n := range items {
if n.id != "" {
ids = append(ids, n.id)
}
}
if err := m.st.DeleteNotices(ids); err != nil {
log.Printf("[runmgr] notice delete: %v", err)
}
}
func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
if len(items) == 0 {
return nil
@@ -1631,6 +2160,25 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
for _, n := range items {
fmt.Fprintf(&b, "后台子代理「%s」已结束。\n状态:%s\n%s\n", n.name, n.payload.Status, strings.TrimSpace(n.payload.Summary))
}
// 材料整理完成 → 交付物选择职责(I2):汇报要点后立即发起待确认选择卡。
// runtime 侧对不发 confirm_choice 的弱模型另有 stop 兜底(一次性)。
materialSeen := false
for _, n := range items {
if n.material {
materialSeen = true
break
}
}
if materialSeen {
b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n")
if m.genuiAuto != nil {
if ok, _ := m.genuiAuto.Try(owner, convID, "material_done"); ok {
b.WriteString("\n")
b.WriteString(genUIAutoReminderText())
b.WriteString("\n")
}
}
}
b.WriteString("</system-reminder>")
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
@@ -1734,13 +2282,34 @@ func partsText(parts []core.Part) string {
var sb strings.Builder
for _, p := range parts {
if p.Type == core.PartText {
sb.WriteString(p.Text)
text := p.Text
// MiniMax 等模型把推理写成行内 <think> 块:子代理摘要/回投卡正文
// 只取 think 之外的正文(实测 <think> 前缀泄漏进收件箱卡片与通知)。
if body := stripInlineThink(text); strings.TrimSpace(body) != "" || !strings.Contains(text, "</think>") {
text = body
}
sb.WriteString(text)
sb.WriteString("\n")
}
}
return strings.TrimSpace(sb.String())
}
// stripInlineThink 去掉文本中的 <think>…</think> 段(完整闭合才剥,半截保守保留)。
func stripInlineThink(text string) string {
for {
start := strings.Index(text, "<think>")
if start < 0 {
return text
}
end := strings.Index(text[start:], "</think>")
if end < 0 {
return text
}
text = text[:start] + text[start+end+len("</think>"):]
}
}
func artifactSummary(parts []core.Part) string {
var bits []string
seen := map[string]bool{}
@@ -0,0 +1,72 @@
package runmgr
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):回合内检索(knowledge_search
// 工具与 @knowledge 注入的同一入口 runCtx.Retrieve)在适配器放行后吃到共享命中;
// 未放行/未注册谓词时保持本人两路、零泄露。
import (
"context"
"testing"
core "agent-system-core"
)
type sharedFakeRetriever struct {
own []core.RetrievalHit
shared []core.RetrievalHit
gotAlt bool
}
func (f *sharedFakeRetriever) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return f.own, nil
}
func (f *sharedFakeRetriever) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
f.gotAlt = true
out := append([]core.RetrievalHit{}, f.own...) // 对齐 rag.Pipeline:本人两路如常
for _, h := range f.shared {
if allow(h.Doc.Owner, h.Doc.ProjectID) {
out = append(out, h)
}
}
return out, nil
}
func TestRunContextRetrieveShared(t *testing.T) {
m := New(nil, nil, nil, nil, nil, nil, nil, nil, nil)
fr := &sharedFakeRetriever{
own: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "own-1", Chunk: "本人命中"}}},
shared: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "sh-1", Chunk: "共享命中", Owner: "alice", ProjectID: "p-1"}}},
}
m.SetRetriever(fr)
rc := &runCtx{mgr: m, run: core.Run{Owner: "bob"}}
// 未注册共享谓词:本人两路如常,不触碰共享通道。
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
t.Fatalf("no predicate: hits=%+v err=%v", hits, err)
}
if fr.gotAlt {
t.Fatal("no predicate: must not call RetrieveShared(未注册=现状)")
}
// 注册谓词(放行 bob×p-1)→ 本人 + 共享。
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
return owner == "bob" && string(projectID) == "p-1"
})
fr.gotAlt = false
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 2 {
t.Fatalf("with predicate: hits=%+v err=%v, want own+shared", hits, err)
}
if !fr.gotAlt {
t.Fatal("with predicate: RetrieveShared not consulted(EXT-13)")
}
// 谓词拒绝 → 只剩本人。
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { return false })
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
t.Fatalf("denied: hits=%+v err=%v, want own only", hits, err)
}
}
-1
View File
@@ -138,7 +138,6 @@ func TestSpawnBackgroundRecordsDetach(t *testing.T) {
}
}
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
// completed 终态挡续跑。
func TestResumeAfterGracefulInterruptMarker(t *testing.T) {
+11 -1
View File
@@ -128,6 +128,16 @@ func (m *Manager) ResumeInterruptedChildren() {
return
}
for _, run := range runs {
// 恢复隔离(协作存储 U3):未知外部副作用的 run 不自动续跑——hold
// 未解除即跳过;判定出错同样跳过(fail-closed:宁可人工核对,不盲重派)。
if held, herr := m.st.HasOpenRecoveryHold(string(run.ID), string(run.ConversationID)); herr != nil || held {
if herr != nil {
log.Printf("[runmgr] resume skip %s: recovery hold 判定失败(fail-closed): %v", run.ID, herr)
} else {
log.Printf("[runmgr] resume skip %s: recovery hold 未解除(人工核对后显式释放才会续跑)", run.ID)
}
continue
}
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
continue
@@ -158,7 +168,7 @@ func (m *Manager) ResumeInterruptedChildren() {
continue
}
marker, _ := json.Marshal(map[string]string{
"newRun": newID,
"newRun": newID,
"resumedAt": time.Now().UTC().Format(time.RFC3339),
})
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
+1 -1
View File
@@ -101,7 +101,7 @@ func (st *directRun) composeUserPrompt(draft, notice string) string {
}
b.WriteString("\n交付物:\n")
names := 0
for _, ref := range st.deliverables {
for _, ref := range st.latestDeliverables() {
if ref == nil || strings.TrimSpace(ref.Name) == "" {
continue
}
+51
View File
@@ -0,0 +1,51 @@
package runtime
import (
"strings"
"testing"
core "agent-system-core"
)
// 窗口按 token 配置并换算为字符阈值;此前 32000 字符硬默认会让长材料任务
// 在第一个请求就触发清理(被清内容只留「见会话记录」,模型无法取回)。
func TestContextWindowScalesFromTokens(t *testing.T) {
eff := core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
"contextWindow": float64(128000),
"outputReserve": float64(16384),
"contextBuffer": float64(4096),
}}}
w, r, b := contextWindow(eff)
if w != int(128000*3.35) {
t.Fatalf("window = %d, want %d", w, int(128000*3.35))
}
if r <= 0 || b <= 0 {
t.Fatalf("reserve/buffer must be positive chars: %d %d", r, b)
}
// 旧部署未配置时的默认:不再是 32k 字符。
def, _, _ := contextWindow(core.EffectiveSettings{})
if def < 100000*3 {
t.Fatalf("default window too small: %d", def)
}
}
func TestThinkReplayEnabled(t *testing.T) {
eff := func(v string) core.EffectiveSettings {
return core.EffectiveSettings{"context": map[string]any{"thinkReplay": v}}
}
if !thinkReplayEnabled(eff(""), "MiniMax-M2.7-highspeed") {
t.Fatal("auto should enable for minimax models")
}
if thinkReplayEnabled(eff(""), "gpt-5.1") {
t.Fatal("auto should stay off for other models")
}
if !thinkReplayEnabled(eff("on"), "gpt-5.1") {
t.Fatal("explicit on must win")
}
if thinkReplayEnabled(eff("off"), "MiniMax-M2.7-highspeed") {
t.Fatal("explicit off must win")
}
if !strings.Contains("checkpoint", "checkpoint") {
t.Fatal("sanity")
}
}
@@ -0,0 +1,37 @@
package runtime
import (
"testing"
core "agent-system-core"
)
// 修订(同名再登记)后,终轮检查与交付清单只看最新版本;
// 无名工件互不合并。
func TestLatestDeliverablesKeepsLastPerName(t *testing.T) {
st := &directRun{deliverables: []*core.ArtifactRef{
{ArtifactID: "art_aaa1", Name: "方案.docx"},
{ArtifactID: "art_bbb2", Name: "纪要.docx"},
{ArtifactID: "art_ccc3", Name: "方案.docx"},
{ArtifactID: "art_ddd4", Name: ""},
{ArtifactID: "art_eee5", Name: "tool-run_shell-call_x1.txt", Kind: "data"},
}}
got := st.latestDeliverables()
if len(got) != 3 {
t.Fatalf("want 3 deliverables, got %d: %+v", len(got), got)
}
for _, ref := range got {
if isToolDumpArtifact(ref) {
t.Fatalf("tool result dumps are not deliverables: %+v", ref)
}
}
if got[0].ArtifactID != "art_ccc3" || got[0].Name != "方案.docx" {
t.Fatalf("方案.docx should resolve to latest: %+v", got[0])
}
if got[1].ArtifactID != "art_bbb2" {
t.Fatalf("纪要.docx should be untouched: %+v", got[1])
}
if got[2].ArtifactID != "art_ddd4" {
t.Fatalf("unnamed artifact must be kept as-is: %+v", got[2])
}
}
+180
View File
@@ -0,0 +1,180 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// 双速提醒经内核 Reminders 钩子进入下一请求的消息(不是响应部件):
// 这里捕获每次模型请求的原文,检索提醒标记。
type dualSpeedRecorder struct {
clock *stepClock
turns [][]agentllm.Event
request []string
base agentllm.StreamFunc
}
// stepClock 每 tick 前进 5 秒;runDirect 起 0 时刻,模型每被调用一次 tick 一格,
// 20 次调用即 100 秒量级,足以压过默认 75 秒阈值。
type stepClock struct{ step int64 }
func (c *stepClock) now() time.Time { return time.Unix(0, c.step*int64(5*time.Second)) }
func (c *stepClock) tick() { c.step++ }
func (rec *dualSpeedRecorder) stream(base agentllm.StreamFunc) agentllm.StreamFunc {
return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
rec.clock.tick()
for _, m := range msgs {
if b, err := json.Marshal(m); err == nil {
rec.request = append(rec.request, string(b))
}
}
return base(ctx, model, msgs, tools, opts)
}
}
func (rec *dualSpeedRecorder) nudged() bool {
for _, r := range rec.request {
if strings.Contains(r, "任务快慢双速") {
return true
}
}
return false
}
func runDualSpeed(t *testing.T, turns [][]agentllm.Event, setup func(rc *fakeRunCtx)) *dualSpeedRecorder {
t.Helper()
rec := &dualSpeedRecorder{clock: &stepClock{}, turns: turns}
r := &LLMRunner{Stream: rec.stream(scriptStream(turns, nil)), Now: rec.clock.now}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}
if setup != nil {
setup(rc)
}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
return rec
}
func toolOnlyTurns(n int) [][]agentllm.Event {
var turns [][]agentllm.Event
for i := 0; i < n; i++ {
// 每次调用用独立 ID(toolCall 帮手的 ID 固定,重复会撞内核的工具槽位)。
call := agentllm.ToolCall{ID: fmt.Sprintf("c-ds-%d", i), Name: "make_report", Arguments: "{}"}
turns = append(turns, textDeltaTurn("", call))
}
return turns
}
// 长时间纯工具轮、用户零答复 → 阈值后恰好注入一次双速提醒。
func TestDualSpeedNudgeFiresOnceAfterThreshold(t *testing.T) {
turns := append(toolOnlyTurns(20), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, nil)
if !rec.nudged() {
t.Fatal("dual-speed nudge missing after threshold with no user reply")
}
count := 0
for _, r := range rec.request {
count += strings.Count(r, "任务快慢双速")
}
if count > 1 {
t.Fatalf("nudge injected %d times, want at most once per request window", count)
}
}
// 短任务(阈值内完成)不提醒。
func TestDualSpeedNudgeQuietBeforeThreshold(t *testing.T) {
turns := append(toolOnlyTurns(1), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, nil)
if rec.nudged() {
t.Fatal("nudge fired before the threshold")
}
}
// 设置 runtime.dualSpeedNudgeSeconds=0 → 关闭,超时也不提醒。
func TestDualSpeedNudgeDisabledBySetting(t *testing.T) {
turns := append(toolOnlyTurns(20), textDeltaTurn("完成。"))
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.runtimeExtra = map[string]any{"dualSpeedNudgeSeconds": float64(0)}
})
if rec.nudged() {
t.Fatal("nudge fired although disabled via runtime.dualSpeedNudgeSeconds=0")
}
}
// 模型已给出面向用户的答复(快通道成立)→ 即使后续事实核查续写拖过阈值也不提醒。
func TestDualSpeedNudgeSkippedWhenReplied(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
textDeltaTurn("完整总结:产出物与要点。"),
textDeltaTurn("已修正并重新登记。"),
}
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.verify = true
rc.execArtifact = &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"}
rc.execArtifactContent = []byte("# 报告\n\n准确率 95%。")
})
if rec.nudged() {
t.Fatal("nudge fired although the model already gave its user reply")
}
}
// 已把深度工作放后台(慢通道成立)→ 不再提醒。
func TestDualSpeedNudgeSkippedWhenDelegated(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("delegate_task", `{"name":"深度解析","task":"逐页解析","background":true}`)),
}
turns = append(turns, toolOnlyTurns(19)...)
turns = append(turns, textDeltaTurn("后台进行中,先给结论。"))
rec := runDualSpeed(t, turns, func(rc *fakeRunCtx) {
rc.bgID = "run_bg_1"
})
if rec.nudged() {
t.Fatal("nudge fired although background delegation already happened")
}
}
// 首次被无视后按 45s 间隔升级重复(最多 3 次),第二次起为硬性措辞。
func TestDualSpeedNudgeEscalates(t *testing.T) {
turns := append(toolOnlyTurns(35), textDeltaTurn("完成。")) // 35×5s=175s → 75s 起、120s、165s 三档
rec := runDualSpeed(t, turns, nil)
marks, hard := 0, 0
for _, r := range rec.request {
marks += strings.Count(r, "用户已经等待")
hard += strings.Count(r, "硬性要求")
}
if marks < 1 {
t.Fatalf("escalation missing: requests=%d", len(rec.request))
}
if marks > 2 {
t.Fatalf("escalated %d times, want at most 2 repeats after the first", marks)
}
}
// 入口前置:带附件的任务第 0 回合注入快慢双速选项;无附件不注入;阈值关闭不注入。
func TestDualSpeedEntryHintWithAttachments(t *testing.T) {
withAtt := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, func(rc *fakeRunCtx) {
rc.attachments = []core.ParsedDocument{{Meta: core.DocMeta{Filename: "meeting.docx"}}}
})
if !strings.Contains(strings.Join(withAtt.request, "\n"), "快慢双速") {
t.Fatal("entry hint missing for an attachment-bearing task")
}
noAtt := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, nil)
if strings.Contains(strings.Join(noAtt.request, "\n"), "本任务附带材料") {
t.Fatal("entry hint fired without attachments")
}
disabled := runDualSpeed(t, [][]agentllm.Event{textDeltaTurn("完成。")}, func(rc *fakeRunCtx) {
rc.attachments = []core.ParsedDocument{{Meta: core.DocMeta{Filename: "meeting.docx"}}}
rc.runtimeExtra = map[string]any{"dualSpeedNudgeSeconds": float64(0)}
})
if strings.Contains(strings.Join(disabled.request, "\n"), "本任务附带材料") {
t.Fatal("entry hint fired although the nudge setting is disabled")
}
}
+112
View File
@@ -0,0 +1,112 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"testing"
core "agent-system-core"
)
// ---- E4:R4 升级提醒 ----
// 滑动窗口判据:近 6 次结果中 3 次失败(可与成功交错)触发换路指令;
// 每 run 一次;2 次失败不触发。
func TestEscalationOnConsecutiveToolFailures(t *testing.T) {
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error")
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error")
if got := st.escalationReminders(); len(got) != 0 {
t.Fatalf("two scattered failures must not escalate: %v", got)
}
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error") // 窗口内第 3 败(交错分布)
got := st.escalationReminders()
if len(got) != 1 || !strings.Contains(got[0], "停止原样重试") {
t.Fatalf("three failures in the window must escalate: %v", got)
}
if again := st.escalationReminders(); len(again) != 0 {
t.Fatalf("escalation must fire once per run: %v", again)
}
}
// 检索连续空结果(成功但无实质内容)触发「换关键词或如实写缺失」。
func TestEscalationOnZeroHitSearches(t *testing.T) {
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "未找到相关内容"}
r := &LLMRunner{Stream: scriptStream(padTurns(
textDeltaTurn("", toolCall("knowledge_search", `{"query":"a"}`), toolCall("knowledge_search", `{"query":"b"}`)),
textDeltaTurn("好了。"),
), nil)}
if err := r.Execute(f); err != nil {
t.Fatal(err)
}
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
st.noteEvidenceCall("knowledge_search", "ok", "未找到相关内容")
if got := st.escalationReminders(); len(got) != 0 {
t.Fatalf("one zero hit must not escalate: %v", got)
}
st.noteEvidenceCall("knowledge_search", "ok", "无结果")
got := st.escalationReminders()
if len(got) != 1 || !strings.Contains(got[0], "材料中无此信息") {
t.Fatalf("two zero hits must escalate: %v", got)
}
// 有实质结果后计数重置。
st.noteEvidenceCall("knowledge_search", "ok", "营收 3,200 万元")
if st.zeroHitSearches != 0 {
t.Fatal("substantive hit must reset zero-hit count")
}
}
// ---- E4:结构化 source_id 账本 ----
func TestEvidenceLedgerTagsSource(t *testing.T) {
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "上半年营收 3,200 万元。"}
r := &LLMRunner{Stream: scriptStream(padTurns(
textDeltaTurn("", toolCall("knowledge_search", `{"query":"营收"}`)),
textDeltaTurn("总结完成。"),
), nil)}
if err := r.Execute(f); err != nil {
t.Fatal(err)
}
// 结果文本必须带来源标记,且账本登记了对应条目(直接经 runTool 断言)。
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
res := st.runTool("knowledge_search", `{}`)
if !strings.Contains(res.Content, "【src_1|knowledge_search】") {
t.Fatalf("tool result must carry the source tag: %.120s", res.Content)
}
if len(st.ledger) != 1 || st.ledger[0].ID != "src_1" || !strings.Contains(st.ledger[0].Text, "3,200") {
t.Fatalf("ledger = %+v", st.ledger)
}
// 第二次登记递增编号。
res2 := st.runTool("fs_read_file", `{}`)
if !strings.Contains(res2.Content, "【src_2|fs_read_file】") {
t.Fatalf("second tag must be src_2: %.120s", res2.Content)
}
}
// 非证据工具不登记、不加标记。
func TestEvidenceLedgerSkipsNonEvidenceTools(t *testing.T) {
f := &fakeRunCtx{}
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
res := st.runTool("time_now", `{}`)
if strings.Contains(res.Content, "src_") || len(st.ledger) != 0 {
t.Fatalf("non-evidence tool must not enter the ledger: %.80s %+v", res.Content, st.ledger)
}
}
// 材料读(inputs/)打标;模型自产文件的读与 grep 不打——尾注会污染
// read→edit→build 编辑循环(E4 首轮 eval 实测教训,回归锚点)。
func TestEvidenceLedgerOnlyTagsMaterialReads(t *testing.T) {
if !isEvidenceRead("read_file", json.RawMessage(`{"path":"inputs/年报.docx.md"}`)) {
t.Fatal("inputs/ read must count as evidence")
}
if isEvidenceRead("read_file", json.RawMessage(`{"path":"方案.md"}`)) {
t.Fatal("own-workspace read must NOT count as evidence")
}
if isEvidenceRead("grep", json.RawMessage(`{"pattern":"营收"}`)) {
t.Fatal("grep must never count as evidence")
}
}
+377
View File
@@ -0,0 +1,377 @@
package runtime
import (
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
core "agent-system-core"
"agent-system-backend/internal/quality"
)
// factCheckMaxRounds 是 E3 的事实核查修正轮上限:两轮(第二轮为「最后一次
// 拦截」+标注出路),之后放行并记检查行。
const factCheckMaxRounds = 2
// P5:事实一致性核查。从交付物正文抽取可机械定位的「硬事实 token」——
// 百分比、日期、大额数字——并回溯本轮材料(附件解析文本 + 工具输出证据)。
// 找不到出处的 token 以「未核实」反馈给模型修一轮;模型可以选择删除、
// 改成材料里的数值、或明确标注为推测/目标值。人名/称谓不做机械比对
// (材料里多为"说话人N",机械比对必然误报)。
var (
factPctRe = regexp.MustCompile(`\d+(?:\.\d+)?%`)
factDateRe = regexp.MustCompile(`\d{1,2}月\d{1,2}日|\d{4}年|\d{4}-\d{1,2}-\d{1,2}`)
factBigNumRe = regexp.MustCompile(`\d[\d,]{4,}`)
)
// ExtractFacts 抽取交付物文本里的硬事实 token(去重保序)。
func ExtractFacts(text string) []string {
var out []string
seen := map[string]bool{}
add := func(tok string) {
tok = strings.TrimSpace(tok)
if tok == "" || seen[tok] {
return
}
seen[tok] = true
out = append(out, tok)
}
for _, m := range factPctRe.FindAllString(text, -1) {
add(m)
}
for _, m := range factDateRe.FindAllString(text, -1) {
add(m)
}
for _, m := range factBigNumRe.FindAllString(text, -1) {
add(m)
}
return out
}
// UnverifiedFacts 返回材料里找不到出处的 token。放行三类:豁免的通用口径词、
// 材料中能回溯的、以及 E3 标注感知——文中以「目标/预计/假设」等标注词显式
// 标记为规划量的数字不再是凭空事实,机械溯源放行(MarkedAsEstimate)。
func UnverifiedFacts(artifactText, materialText string, exempt map[string]bool) []string {
if strings.TrimSpace(artifactText) == "" || strings.TrimSpace(materialText) == "" {
return nil
}
var out []string
for _, tok := range ExtractFacts(artifactText) {
if exempt[tok] {
continue
}
if !strings.Contains(materialText, tok) && !strings.Contains(materialText, stripPunct(tok)) && !MarkedAsEstimate(artifactText, tok) {
out = append(out, tok)
}
}
return out
}
// factMarkingRe:数字邻域出现这些词即视为「已标注的规划量」。选取保守集合,
// 避免把「会议目标」之类的普通动词误当标注(需要与数字同现于邻域内)。
var factMarkingRe = regexp.MustCompile(`目标|假设|预计|预估|推测|估算|规划|拟定|建议值|上限|下限|阈值|基准值|参考值|暂定`)
// factClauseCut 是分句分隔符:标注判定以「句」为单位而不是固定窗口——
// 固定窗口会把相邻句的标注词错标给前一个数字(实测:「覆盖 3,500 用户,
// 目标 85%」里 3,500 被误放行)。
const factClauseCut = "。;!?\n,、;"
// MarkedAsEstimate 报告 token 在 text 中的任一出现是否与标注词同句
// (同句=两侧最近分隔符之间,括号不算分隔,覆盖「目标完成率 85%」「85%
// (预计)」)。供终轮核查与评测端共用同一口径。
func MarkedAsEstimate(text, tok string) bool {
from := 0
for {
i := strings.Index(text[from:], tok)
if i < 0 {
return false
}
i += from
lo := strings.LastIndexAny(text[max(0, i-64):i], factClauseCut) + max(0, i-64) + 1
if end := strings.IndexAny(text[i:], factClauseCut); end >= 0 {
clause := text[lo : i+end]
if factMarkingRe.MatchString(clause) {
return true
}
} else if factMarkingRe.MatchString(text[lo:]) {
return true
}
from = i + len(tok)
}
}
func stripPunct(s string) string {
return strings.NewReplacer(",", "", ",", "", " ", "").Replace(s)
}
// factCheckNotice 把未核实 token 组成终轮反馈(给模型的一轮修正机会)。
func factCheckNotice(tokens []string) string {
if len(tokens) == 0 {
return ""
}
var b strings.Builder
b.WriteString("交付物里以下数字/日期在本次材料中找不到出处:")
for i, tok := range tokens {
if i >= 12 {
b.WriteString(" 等")
break
}
if i > 0 {
b.WriteString("、")
}
b.WriteString(tok)
}
b.WriteString("。逐条二选一:改成材料或来源结果(检索/读取输出带【src_N|…】标记)里能找到的数值,或删除/明确标注为推测或目标值(如“目标 85%”)。不要保留无法回溯的裸数字。")
return b.String()
}
// materialCorpus 汇总本轮可见材料:附件解析文本 + 工具证据文本。
// 证据用完整 verifyText(尾部 600 字)而非 160 字摘要——足够定位数字。
func (st *directRun) materialCorpus() string {
var b strings.Builder
for _, doc := range st.rc.Attachments() {
b.WriteString(doc.PlainText())
b.WriteString("\n")
}
for _, ev := range st.evidence {
b.WriteString(ev.VerifyText)
b.WriteString("\n")
}
return b.String()
}
// deliverableText 拼接本轮交付工件的文本内容(UTF-8 文本直接读;docx
// 由 quality 的解析口径承担——这里只收集,不重复解析)。
func (st *directRun) deliverableText(refs []*artifactTextPair) string {
var b strings.Builder
for _, pair := range refs {
b.WriteString(pair.text)
b.WriteString("\n")
}
return b.String()
}
type artifactTextPair struct {
id string
name string
text string
}
// collectDeliverableText 经工件端口读取文本类交付物内容
// (docx 用 quality 同源的抽取口径;二进制工件跳过)。
func (st *directRun) collectDeliverableText(refs []*core.ArtifactRef) []artifactTextPair {
lookup := artifactLookupOf(st.rc)
var out []artifactTextPair
for _, ref := range refs {
if ref == nil || ref.ArtifactID == "" {
continue
}
name, content, ok := lookup(string(ref.ArtifactID))
if !ok || len(content) == 0 {
continue
}
text := deliverablePlainText(name, content)
if text != "" {
out = append(out, artifactTextPair{id: string(ref.ArtifactID), name: name, text: text})
}
}
return out
}
// deliverablePlainText 用 quality 的同源口径抽取工件文本。
func deliverablePlainText(name string, content []byte) string {
return quality.DeliverableText(name, content)
}
// concatArtifactText 拼接工件文本对。
func concatArtifactText(pairs []artifactTextPair) string {
var b strings.Builder
for _, p := range pairs {
b.WriteString(p.text)
b.WriteString("\n")
}
return b.String()
}
// factExemptions 豁免通用口径词(非事实性数字)。
func factExemptions() map[string]bool {
return map[string]bool{
"100%": true, "0%": true, // 常见修辞(100% 确定)与零值
}
}
// factCheckGate 决定本轮未核实 token 的处置(E3):
// - 第一轮:给修正通知并续跑;
// - 第二轮(仅当 token 集合相对上一轮有变化——模型确实改过但仍有残留):
// 「最后一次拦截」+ 标注出路;
// - token 集合与上一轮完全相同(模型没有理会)或轮数用尽:放行,
// 由调用方记入检查行如实呈现。续写预算是各终轮门禁共享的,
// 无变化的重复拦截只会挤占预算(实测会挤掉后续门禁的修正机会)。
func (st *directRun) factCheckGate(tokens []string) (notice string, cont bool) {
same := st.factCheckPrev != nil && len(tokens) == len(st.factCheckPrev)
if same {
for _, tok := range tokens {
if !st.factCheckPrev[tok] {
same = false
break
}
}
}
if st.factCheckRounds >= factCheckMaxRounds || same {
return "", false
}
st.factCheckRounds++
st.factCheckPrev = map[string]bool{}
for _, tok := range tokens {
st.factCheckPrev[tok] = true
}
notice = factCheckNotice(tokens)
if st.factCheckRounds > 1 {
notice = "最后一次拦截:" + notice + " 若该数字确属规划量而非事实,必须在文中用「目标 / 预计 / 假设」等词显式标注(如「目标 85%」);既无出处又不标注的数字不得保留。"
}
return notice, true
}
// ---- E4:证据账本与 R4 升级提醒 ----
// evidenceEntry 是账本的一条来源记录(正文截头保存;全文在转录与工具证据里)。
type evidenceEntry struct {
ID string
Tool string
Text string
}
// evidenceTools 无条件登记进 source_id 账本的证据类工具(检索面)。
// 内核工作区 read_file 不在此列:它也读模型自己的工作文件——给自身文件
// 的读结果尾注【src_N】会污染 read→edit→build 的编辑循环(实测 E4 首轮
// eval:模型视尾注为文件被外改动,反复重读重试,工具失败 10→24、调用
// 63→166)。仅当读取 inputs/ 材料时才登记(isEvidenceRead)。
var evidenceTools = map[string]bool{
"knowledge_search": true,
"web_search": true,
"tavily_search": true,
"fs_read_file": true,
}
// isEvidenceRead 判定一次工作区读是否为「材料读」:仅 inputs/(上传材料
// 落点,环境段列出)之下的 read_file 计入账本;grep 无法区分材料与自产
// 文件,一律不计。
func isEvidenceRead(name string, args json.RawMessage) bool {
if name != "read_file" {
return false
}
var obj map[string]string
if err := json.Unmarshal(args, &obj); err != nil {
return false
}
p := strings.TrimSpace(obj["path"])
p = strings.TrimPrefix(p, "./")
return strings.HasPrefix(p, "inputs/") || strings.HasPrefix(p, "/inputs/")
}
// R4 换路提醒的滑动窗口判据:最近 escalationWindow 次工具结果里出现
// escalationFailureThreshold 次失败即触发(每 run 一次)。用窗口而不是
// 「连续 N 次」——实测失败多与成功交错(20 失败散布在 108 次调用里,
// 连续计数从未达 2,首轮 eval 生产路径零触发),滑动窗口才抓得住
// 「整体在原地打转」的形态。
const (
escalationWindow = 6
escalationFailureThreshold = 3
// 检索空命中阈值独立取 2:检索单次成本高,两次落空即是换维度信号。
escalationZeroHitThreshold = 2
)
// tagSource 把一次成功的证据工具输出登记进账本,返回给模型看的来源标记
// (如 src_3|knowledge_search)。并行执行下由 toolMu 串行化计数与追加。
func (st *directRun) tagSource(tool, text string) (string, bool) {
st.toolMu.Lock()
defer st.toolMu.Unlock()
st.ledgerCount++
id := fmt.Sprintf("src_%d", st.ledgerCount)
head := []rune(text)
if len(head) > 2000 { // 账本只存索引性头部,全文在转录/证据里
head = head[:2000]
}
st.ledger = append(st.ledger, evidenceEntry{ID: id, Tool: tool, Text: string(head)})
return id + "|" + tool, true
}
// noteEvidenceCall 维护 R4 的检索空命中计数(成功但无实质内容的证据类
// 工具调用)。工具成败计数不在这里——并行下以 onEvent 终点的槽位状态为
// 单一事实源(noteFailureOutcome),避免两处重复计数。
func (st *directRun) noteEvidenceCall(name, status, output string) {
if status != "ok" {
return
}
st.toolMu.Lock()
defer st.toolMu.Unlock()
trimmed := strings.TrimSpace(output)
if evidenceTools[name] && (trimmed == "" || containsAny(trimmed, zeroHitMarkers)) {
st.zeroHitSearches++
} else if evidenceTools[name] {
st.zeroHitSearches = 0
}
}
// zeroHitMarkers 是检索结果「无命中」的常见表述(按工具输出原文判别)。
var zeroHitMarkers = []string{"未找到", "没有找到", "无结果", "无相关", "暂无匹配", "No results"}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// escalationReminders 返回待注入的换路提醒并清标记(每类条件一次,
// 连续失败再次触发时会重新提醒——失败计数在成功后重置,streak 标记随重置)。
// 文案直接给动作:MiniMax 对泛泛提醒不敏感(实测),对具体指令照办。
func (st *directRun) escalationReminders() []string {
var out []string
st.toolMu.Lock()
fails, failNoted := countFails(st.recentOutcomes), st.failWindowNoted
zero, zeroNoted := st.zeroHitSearches, st.zeroHitNoted
if fails >= escalationFailureThreshold && !failNoted {
st.failWindowNoted = true
}
if zero >= escalationZeroHitThreshold && !zeroNoted {
st.zeroHitNoted = true
}
st.toolMu.Unlock()
if fails >= escalationFailureThreshold && !failNoted {
log.Printf("[runtime] r4 escalation reminder injected (tool failures %d in last %d outcomes)", fails, escalationWindow)
out = append(out, fmt.Sprintf("工具最近集中失败(近 %d 次调用中多次报错):停止原样重试。逐条重读错误信息,改参数、换工具或换路径再试;确认无法绕过时,如实在答复里说明卡点与已尝试的手段,不要编造结果。", fails))
}
if zero >= escalationZeroHitThreshold && !zeroNoted {
log.Printf("[runtime] r4 escalation reminder injected (zero-hit searches %d)", zero)
out = append(out, "检索已连续多次无结果:换关键词/换检索维度再试一次;仍无结果就如实写「材料中无此信息」,不要凭印象补数字。")
}
return out
}
// noteFailureOutcome 供 onEvent 在工具结束时记录成败(并行下事件乱序,
// 以 runToolCtx 已写入槽位的 status 为准)。维护最近 escalationWindow 次
// 结果的滑动窗口。
func (st *directRun) noteFailureOutcome(status string) {
st.toolMu.Lock()
defer st.toolMu.Unlock()
st.recentOutcomes = append(st.recentOutcomes, status == "error")
if len(st.recentOutcomes) > escalationWindow {
st.recentOutcomes = st.recentOutcomes[len(st.recentOutcomes)-escalationWindow:]
}
}
func countFails(outcomes []bool) int {
n := 0
for _, failed := range outcomes {
if failed {
n++
}
}
return n
}
+125
View File
@@ -0,0 +1,125 @@
package runtime
import (
"context"
"strings"
"testing"
core "agent-system-core"
)
// 事实核查:交付物里材料找不到出处的百分比被抽出并组成反馈;找到的不报。
func TestUnverifiedFactsAndNotice(t *testing.T) {
material := "目标是准确率 85%以上。截止 10月12日。团队共 12 人。"
artifact := "准确率达到 95%,截止 10月20日,覆盖 3,500 用户,目标 85%。"
got := UnverifiedFacts(artifact, material, factExemptions())
joined := strings.Join(got, ",")
for _, want := range []string{"95%", "10月20日", "3,500"} {
if !strings.Contains(joined, want) {
t.Fatalf("missing %q in %v", want, got)
}
}
if strings.Contains(joined, "85%") {
t.Fatalf("sourced figure must not be reported: %v", got)
}
notice := factCheckNotice(got)
if !strings.Contains(notice, "95%") || !strings.Contains(notice, "改成材料或来源结果") {
t.Fatalf("notice malformed: %q", notice)
}
if UnverifiedFacts("", material, nil) != nil || UnverifiedFacts(artifact, "", nil) != nil {
t.Fatal("empty sides short-circuit")
}
}
// 端到端:终轮对带未核实数字的交付物给一轮修正;修正后(重新登记干净版本)放行。
func TestFactCheckContinuationOnce(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
verify: true,
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"},
execArtifactContent: []byte("# 报告\n\n准确率 95%。"),
}
// 第一轮:登记带编造数字的工件 -> 事实核查续跑一次;第二轮:干净版本通过。
turns1 := padTurns(
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
textDeltaTurn("写好了。"),
)
turns2 := padTurns(
textDeltaTurn("材料里是 85%。"),
)
r := &LLMRunner{Stream: scriptStream(padTurns(turns1...), nil), VerifyStream: passedStream()}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
sawFact := false
for _, p := range rc.assistant {
if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") {
sawFact = true
}
}
if !sawFact {
t.Fatalf("fact check continuation note missing: %+v", rc.assistant)
}
_ = turns2
}
// ---- E3:标注感知豁免 ----
func TestMarkedAsEstimate(t *testing.T) {
cases := []struct {
name, text, tok string
want bool
}{
{"前置标注", "目标完成率 85%,分三个阶段推进", "85%", true},
{"后置标注", "完成率 85%(预计)", "85%", true},
{"裸数字", "当前完成率 85%,尚有差距", "85%", false},
{"邻域外有标注词", "目标是建成样板。完成率 85%", "85%", false},
{"多次出现其一标注", "实际 70%。目标 85% 年内达成", "85%", true},
{"推测标注", "用户规模约 120,000(推测)", "120,000", true},
}
for _, c := range cases {
if got := MarkedAsEstimate(c.text, c.tok); got != c.want {
t.Errorf("%s: MarkedAsEstimate(%q) = %v, want %v", c.name, c.tok, got, c.want)
}
}
}
func TestUnverifiedFactsMarksPass(t *testing.T) {
material := "材料:上半年营收 3,200 万元。"
// 「目标 85%」已标注 → 放行;裸 95% → 仍拦。
artifact := "上半年营收 3,200 万元,达成率目标 85%,试点扩面后裸数字 95%。"
got := UnverifiedFacts(artifact, material, factExemptions())
if len(got) != 1 || got[0] != "95%" {
t.Fatalf("unverified = %v(应只拦未标注的 95%%)", got)
}
}
// ---- E3:两轮修正的轮次逻辑 ----
func TestFactCheckGateRounds(t *testing.T) {
st := &directRun{}
first := []string{"95%", "10月20日"}
// 第一轮:拦截并给修正通知。
notice, cont := st.factCheckGate(first)
if !cont || !strings.Contains(notice, "95%") || strings.Contains(notice, "最后一次拦截") {
t.Fatalf("round1: cont=%v notice=%q", cont, notice)
}
// token 完全没变(模型没理会):不再拦——重复拦截只挤占共享续写预算。
if _, cont := st.factCheckGate(first); cont {
t.Fatal("unchanged tokens must pass through")
}
// token 有变化(改了一个、残留一个):第二轮升级为「最后一次拦截」。
notice, cont = st.factCheckGate([]string{"10月20日"})
if !cont || !strings.Contains(notice, "最后一次拦截") || !strings.Contains(notice, "目标") {
t.Fatalf("round2: cont=%v notice=%q", cont, notice)
}
// 轮数用尽:放行。
if _, cont := st.factCheckGate([]string{"7%"}); cont {
t.Fatal("rounds exhausted must pass through")
}
if st.factCheckRounds != 2 {
t.Fatalf("rounds = %d", st.factCheckRounds)
}
}
+97
View File
@@ -0,0 +1,97 @@
package runtime
import (
"context"
"strings"
"testing"
core "agent-system-core"
)
// 问题一回归(run_b520bf2e3037):事实核查修正轮触发时,现场那段正式总结必须
// 降级进过程时间线;修正轮的收尾才是最终答复——否则前端按「最后一个过程部件」
// 切分后,用户只看到修正流水账,看不到原总结。
func TestFactCheckDemotesPriorSummary(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model", Lightweight: "lite-model"}},
verify: true,
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "报告.md"},
execArtifactContent: []byte("# 报告\n\n准确率 95%。"),
}
turns := padTurns(
textDeltaTurn("", toolCall("make_report", `{"content":"准确率 95%"}`)),
textDeltaTurn("已完成。## 产出物\n\n结构完整的用户总结。"),
textDeltaTurn("已修正并重新登记。"),
)
r := &LLMRunner{Stream: scriptStream(turns, nil), VerifyStream: passedStream()}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
var summary *core.Part
visible := []string{}
sawFactCheck := false
for i := range rc.assistant {
p := rc.assistant[i]
if p.Type == core.PartText && strings.Contains(p.Text, "结构完整的用户总结") {
summary = &rc.assistant[i]
}
if p.Type == core.PartText && !p.Process {
visible = append(visible, p.Text)
}
if p.Check != nil && !p.Check.Passed && strings.Contains(p.Check.Reason, "事实核查") {
sawFactCheck = true
}
}
if summary == nil {
t.Fatal("prior summary text missing")
}
if !summary.Process {
t.Fatalf("prior summary not demoted to process: %+v", *summary)
}
if !sawFactCheck {
t.Fatal("fact check note missing")
}
if len(visible) != 1 || visible[0] != "已修正并重新登记。" {
t.Fatalf("visible final texts = %v, want only the continuation closing", visible)
}
}
// 同源跳过提示不再作为用户可见正文追加(原先经 StopDecision.Notice 顶掉正式总结),
// 改记为通过态检查行留在过程时间线。
func TestVerifierSkipIsCheckNotFinalText(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, // 轻量回落=主模型 → 同源跳过
verify: true,
}
turns := padTurns(textDeltaTurn("总结完成。"))
r := &LLMRunner{Stream: scriptStream(turns, nil)} // 无 VerifyStream
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
skipCheck, noticeText := false, false
visible := []string{}
for _, p := range rc.assistant {
if p.Check != nil && p.Check.Passed && strings.Contains(p.Check.Reason, "同源模型验证") {
skipCheck = true
}
if p.Type == core.PartText {
if strings.Contains(p.Text, verifierUnavailableNotice) {
noticeText = true
}
if !p.Process {
visible = append(visible, p.Text)
}
}
}
if !skipCheck {
t.Fatal("same-source skip not recorded as a passing check")
}
if noticeText {
t.Fatal("skip notice leaked into message text parts")
}
if len(visible) != 1 || visible[0] != "总结完成。" {
t.Fatalf("visible final texts = %v", visible)
}
}
+97 -19
View File
@@ -11,12 +11,12 @@ import (
nruntime "agent-native/runtime"
"agent-native/runtime/gates"
agentllm "agent-native/runtime/llm"
agentmemory "agent-native/runtime/memory"
"agent-native/runtime/policy"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/quality"
agentmemory "agent-native/runtime/memory"
core "agent-system-core"
)
@@ -67,10 +67,14 @@ const (
toolCardBegin = "card_begin"
toolCardAppend = "card_append"
toolCardCommit = "card_commit"
// confirm_choice 发起待确认选择卡(材料整理完成后选交付物;I2)。
toolConfirmChoice = "confirm_choice"
)
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目。
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string) {
// resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目
// 与本轮命中的 task 层模块(转录权威模式下系统段只在播种时存在,后续轮的新命中
// 模块经 Reminders 注入)。
func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message, []string, []core.PromptBlock) {
userText := currentUserText(rc)
atts := rc.Attachments()
names := make([]string, len(atts))
@@ -98,7 +102,7 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
Text: userText, HasAttachments: len(atts) > 0,
}, env, prof.Owner)
if err != nil {
return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}
return nil, []string{"prompt(库解析失败:" + err.Error() + ")"}, nil
}
// 卡流注入(D39/D42):ui.cardflow 开启时附加 style.cardflow 模块正文。
// 不走 PromptModules(向空列表追加会翻转 pick 的"显式引用"语义),独立成块。
@@ -109,10 +113,14 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
}
}
msgs := make([]agentllm.Message, 0, len(blocks))
var obs []string
var taskBlocks []core.PromptBlock
for _, b := range blocks {
msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: b.Body})
if b.Layer == core.LayerTask {
taskBlocks = append(taskBlocks, b)
}
}
var obs []string
for _, h := range hits {
if h.Injected {
obs = append(obs, fmt.Sprintf("prompt(%s v%d · %s)", h.ModuleID, versionOf(blocks, h.ModuleID), h.Reason))
@@ -120,7 +128,7 @@ func (l *LLMRunner) resolveSystem(rc RunCtx, model string) ([]agentllm.Message,
obs = append(obs, fmt.Sprintf("skill-yield(%s · %s)", h.ModuleID, h.Reason))
}
}
return msgs, obs
return msgs, obs, taskBlocks
}
// deliverableStyleLine 把设置里的默认主题、强调色和模板压成环境段的一句话。
@@ -139,6 +147,28 @@ func deliverableStyleLine(eff core.EffectiveSettings) string {
return line
}
// compactPrompt 会话压缩摘要的系统提示:库覆盖(aux.session-compact)优先,
// 回退九段结构化摘要的内联文案(与提示词库模板同构)。
func (l *LLMRunner) compactPrompt(owner string) string {
if l.Prompts != nil {
if body, ok, err := l.Prompts.ModuleBody(owner, "aux.session-compact"); err == nil && ok && strings.TrimSpace(body) != "" {
return body
}
}
return `你是会话压缩器。把此前的对话压缩为一份完整摘要,供后续会话作为唯一上下文继续工作。压缩期间不得调用任何工具,纯文本输出:先在 <analysis> 块内按时间顺序梳理对话(用户意图、采用的方法、关键决策、涉及的文件与数据细节、错误与修复、用户给过的反馈),再输出 <summary> 块。
摘要必须包含九部分:
1. 主要请求和意图:用户要什么、为什么。
2. 关键技术概念:领域、技术栈、核心机制。
3. 文件和数据:涉及的文件/对象路径、关键代码或数据片段、谁改了什么。
4. 错误和修复:出现过的问题与解法。
5. 问题解决过程:尝试过什么、哪些有效哪些放弃。
6. 所有用户消息:逐条保留(含澄清与反馈)。
7. 待办任务:尚未完成的事项。
8. 当前状态:进行到哪一步、最新状态。
9. 下一步计划:用户或执行者提出的后续动作。`
}
// toolDesc 工具描述:库覆盖(tool.<name>)优先,回落注册表文案。
func (l *LLMRunner) toolDesc(owner, name, fallback string) string {
if l.Prompts != nil {
@@ -202,12 +232,19 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
if route.Model == "" || route.Base == "" {
return fmt.Errorf("llm: 主模型未配置(当前解析:model=%q base=%q;检查 settings.models.chat 与画像 ModelRoute;演示模式可开 runtime.mock)", route.Model, route.Base)
}
// 终轮评分的 lint 子进程与 run_python 同一技能根:不注入的话 housestyle
// 在后端进程里 import 失败,lint 静默变空结果(评分门禁半边失效)。
// 装配放这里而不是 app 组合根:runtime 已依赖 quality(qualityScorer),
// app→quality 会被架构棘轮拦截;幂等,进程级只需设一次。
if l.SkillsDir != "" {
quality.SetLintEnvRoot(l.SkillsDir)
}
stream := l.Stream
if stream == nil {
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
// 此处不再显式包装(防双重)。
client := openLLMClient(route.Base, route.Key)
client := openLLMClient(route.Base, route.Key, route.Model)
stream = client.Stream
}
@@ -225,15 +262,19 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
currentUser, _ := lastAddressed(hist)
var sys []agentllm.Message
var promptObs []string
var taskBlocks []core.PromptBlock
if l.Prompts != nil {
sys, promptObs = l.resolveSystem(rc, route.Model)
sys, promptObs, taskBlocks = l.resolveSystem(rc, route.Model)
}
// 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。
resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) }
// 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入,
// 组装侧不再注入(防双份;注入文案同一形态)。
_ = ctxasm.InjectMemory
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
Surfaces: rc.Surfaces(),
ThinkReplay: thinkReplayEnabled(eff, route.Model),
})
tail := ctxasm.Tail{
Instructions: eff.GetString("context.userInstructions", ""),
History: hist,
@@ -269,10 +310,11 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
decls = append(decls, agentllm.ToolDecl{Name: toolSquad, Description: l.toolDesc(prof.Owner, toolSquad, "用小队编排引擎执行多步骤目标:规则规划器拆解为 梳理→专项→成文→终审 步骤并逐步执行(每步一个子代理),挂起决策会以提问卡片征求用户"), Parameters: json.RawMessage(`{"type":"object","properties":{"goal":{"type":"string","description":"要完成的整体目标描述"}},"required":["goal"]}`)})
}
decls = append(decls,
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回,子代理完成后系统会自动发起一轮汇报"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行,当前回合不等待结果"}},"required":["name","task"]}`)},
agentllm.ToolDecl{Name: toolDelegate, Description: l.toolDesc(prof.Owner, toolDelegate, "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要。模型和工具由子代理 profile 决定,调用方不能指定模型。background 为真时立即返回——这是重任务的慢通道:先在当前回复里给用户即时反馈(相关性判断/概要/计划),深度工作放后台;系统会在收件箱建进度卡并在完成后回投结果、自动发起一轮汇报。预计要跑几分钟的任务(整篇文档深度解析、多文件批量处理)优先用此模式,不要让用户干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"agent":{"type":"string","enum":["general","explore","research","plan","verify","writer"],"description":"子代理 profile,缺省 general"},"name":{"type":"string","description":"显示名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"},"background":{"type":"boolean","description":"为真时在后台运行(慢通道),当前回合不等待结果;收件箱有进度卡,完成后自动汇报"}},"required":["name","task"]}`)},
agentllm.ToolDecl{Name: toolReview, Description: l.toolDesc(prof.Owner, toolReview, "对一段材料做评审:多个审查子代理并行找问题,再逐条独立验证,只保留验证通过的问题"), Parameters: json.RawMessage(`{"type":"object","properties":{"target":{"type":"string","description":"要评审的文本或工件"},"focus":{"type":"string","description":"评审重点"},"reviewers":{"type":"integer","minimum":1,"maximum":3,"description":"并行审查人数,1 到 3,缺省 1"}},"required":["target"]}`)},
agentllm.ToolDecl{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)},
agentllm.ToolDecl{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)},
agentllm.ToolDecl{Name: toolConfirmChoice, Description: l.toolDesc(prof.Owner, toolConfirmChoice, "向用户发起待确认选择卡并等待答复:3-4 个选项(各附一句依据与影响)+ 可选自由输入。材料整理完成后选正式交付物、或任何需要用户拍板的分叉都用它,不要在文本里罗列选项后干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"question":{"type":"string","description":"要用户决定的问题,一句话"},"options":{"type":"array","minItems":2,"maxItems":4,"items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string","description":"选项名,如:会议摘要"},"description":{"type":"string","description":"一句依据(来自材料或上下文)"},"impact":{"type":"string","description":"选择后的产出与耗时影响"},"recommended":{"type":"boolean","description":"最贴合的一项设 true"}},"required":["id","label","description","impact"]}},"free_input":{"type":"boolean","description":"允许自由输入其他要求,默认 true"},"timeout_sec":{"type":"integer","description":"等待上限秒数,0=不限"}},"required":["question","options"]}`)},
)
if eff.GetString("runtime.entry", "direct") != "squad" {
decls = append(decls, agentllm.ToolDecl{
@@ -319,7 +361,13 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens"),
MessageShape: eff.GetString("context.messageShape", "merge"),
}
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools)
// 转录权威模式的逐轮刷新(经 Reminders 注入,不落转录)。
refresh := turnRefresh{
Notes: ctxasm.TailNotes(tail),
EnvUpdate: envUpdateLine(rc, route.Model),
TaskBlocks: taskBlocks,
}
return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools, refresh)
}
func permissionModeOf(rc RunCtx) string {
@@ -362,13 +410,33 @@ func applyToolSelection(decls []agentllm.ToolDecl, spec string) []agentllm.ToolD
return next
}
// contextWindow 把模型真实窗口(token)换算成内核清理阈值用的字符数。
// 内核 EstimateChars 按 rune 计数;按 ctxasm 同口径(UTF-8 字节/3.35 ≈ token,
// 中文约一字一 token)反推 chars ≈ tokens×3.35。此前 32000 字符的硬默认会让
// 带长材料的任务在第一个请求就触发清理,被清内容只留一句「见会话记录」。
func contextWindow(eff core.EffectiveSettings) (window, reserve, buffer int) {
window = int(eff.GetNumber("models.chat.contextWindow", 32000))
reserve = int(eff.GetNumber("models.chat.outputReserve", 4096))
buffer = int(eff.GetNumber("models.chat.contextBuffer", 1024))
tokens := eff.GetNumber("models.chat.contextWindow", 128000)
if tokens < 4096 {
tokens = 4096
}
window = int(tokens * 3.35)
reserve = int(eff.GetNumber("models.chat.outputReserve", 16384) * 3.35)
buffer = int(eff.GetNumber("models.chat.contextBuffer", 4096) * 3.35)
return
}
// thinkReplayEnabled 决定历史 assistant 消息是否原样回传 <think> 块。
// MiniMax 官方要求保留(去掉会掉性能);auto 按模型名识别。
func thinkReplayEnabled(eff core.EffectiveSettings, model string) bool {
switch eff.GetString("context.thinkReplay", "auto") {
case "on":
return true
case "off":
return false
}
return strings.Contains(strings.ToLower(model), "minimax")
}
// loopCaps 把画像预算换成内核参数。轮次或工具数为 0 / 未设置表示不限制;
// 内核对 MaxTurns<=0 会回落到 12,所以不限制时传 MaxInt32。MaxToolCalls 的 0 才是不限制。
func loopCaps(budget core.RunBudget) (maxTurns, maxTools int) {
@@ -424,8 +492,15 @@ func agentNameOf(prof core.AgentProfile) string {
}
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
func openLLMClient(base, key string) *agentllm.Client {
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
func openLLMClient(base, key, model string) *agentllm.Client {
c := &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
// MiniMax 系(interleaved thinking)的 OpenAI 兼容接口默认把思考混在
// content 的 <think> 块里;reasoning_split=true 让思考改走独立
// reasoning_content 字段(SSE 捕获路径不变,正文流不再夹带思考)。
if strings.HasPrefix(model, "MiniMax-M") {
c.ReasoningSplit = true
}
return c
}
// SubLLMRunner 子代理执行器:按内置 profile 跑一轮带工具的内核循环。
@@ -518,10 +593,13 @@ func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
}
}
// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。
func qualityScorer() gates.QualityScorer {
// hardFactScorer 把 quality.ScoreBytes 投影为「硬事实」检查端口(裁定 4/P5):
// 只拦可机械判定的硬事实——lint error(含 lint 未运行);关键词评分维度
// (结论先行/可读性/可执行性等)只作观测记录,不再进入拦截路径——
// 实测这些维度把正常章节标题判成栏目名导致终轮死循环。
func hardFactScorer() gates.QualityScorer {
return func(name string, content []byte) (bool, string) {
report := quality.ScoreBytes(name, content)
report := quality.HardFacts(name, content)
if report.Passed {
return true, ""
}
+521 -60
View File
@@ -3,14 +3,16 @@ package runtime
import (
"context"
"encoding/json"
"fmt"
"log"
"path/filepath"
"fmt"
"strings"
"sync"
"time"
nruntime "agent-native/runtime"
"agent-native/runtime/checkpoint"
"agent-native/runtime/filestate"
"agent-native/runtime/gates"
"agent-native/runtime/hooks"
agentllm "agent-native/runtime/llm"
@@ -47,20 +49,52 @@ type directRun struct {
responding bool
acting bool
queued []agentllm.ToolCall
inflight *toolSlot
inflight map[string]*toolSlot // 并行执行下按内核事件 CallID 配对
toolMu sync.Mutex // 并行 onEvent 下 inflight/queued/finalParts 互斥
turnBody string
evidence []toolEvidence
todos core.TodoList
plan core.PlanSnapshot
closedToolTurn bool
checklistTouched bool
toolTurnsSince int
progress *gates.ProgressReminder
checks []core.CheckPart
servedModel string
switches []string
switchMu sync.Mutex
evidence []toolEvidence
todos core.TodoList
plan core.PlanSnapshot
closedToolTurn bool
checklistTouched bool
toolTurnsSince int
nudgedPlanless bool
factCheckRounds int // E3:事实核查修正轮计数(上限 factCheckMaxRounds)
factCheckPrev map[string]bool // E3:上一轮的未核实 token 集合(未变化则不再拦)
// E4(R4 升级规则):harness 侧触发的换路提醒——不依赖模型自觉发现
// 自己在原地打转。成败计数在 onEvent 终点统一维护(noteFailureOutcome),
// 达阈值注入一次换路指令;检索连续空结果同理(noteEvidenceCall)。
recentOutcomes []bool // E4(R4):最近 N 次工具成败(true=失败),滑动窗口
failWindowNoted bool // E4(R4):窗口判据已提醒(每 run 一次)
zeroHitSearches int
zeroHitNoted bool
// E4(结构化 source_id 账本):证据类工具的成功结果登记为带 ID 的
// 条目,工具输出尾注【src_N|工具】标记——数字溯源的「材料/来源」
// 从此有可指地址。
ledger []evidenceEntry
ledgerCount int
progress *gates.ProgressReminder
checks []core.CheckPart
servedModel string
switches []string
switchMu sync.Mutex
// 任务快慢双速(SR-B10 实路径):started 用注入时钟记 run 起点,
// nudged/replied/delegated 三个标志驱动「超时仍未反馈则提醒转双速」的一次性触发。
started time.Time
nudgedDualSpeedCount int
replied bool
delegatedBackground bool
materialChecked bool // 材料兜底只问一次(runCtx 侧另有幂等,双保险)
// 交付物确认链(I2):confirm_choice 发起过选择卡;用户答复后
// pendingDeliverable 非空——回合结束仍未委派则硬性续跑一轮。
confirmIssued bool
pendingDeliverable string
deliverableNudged bool
// 材料整理通知回合(历史含「材料整理」完成标记):结束仍无选择卡且未委派
// 时,硬性要求发起 confirm_choice(弱模型应急,一次性)。
materialNotice bool
materialNoticeNudged bool
}
type toolEvidence struct {
@@ -92,8 +126,8 @@ func (t directTool) Execute(ctx context.Context, args json.RawMessage) (agenttoo
return t.run(ctx, args), nil
}
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int) error {
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow}
func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model string, msgs []agentllm.Message, decls []agentllm.ToolDecl, streamOpts agentllm.StreamOptions, cardflow bool, eff core.EffectiveSettings, maxTurns, maxTools int, refresh turnRefresh) error {
st := &directRun{l: l, rc: rc, eff: eff, cardflow: cardflow, started: l.now(), inflight: map[string]*toolSlot{}}
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
st.memoryStore = ms.StoreMemory()
@@ -109,6 +143,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
st.pinSubagentArtifacts(text)
}
st.servedModel = model
// 材料整理通知回合识别(I2):历史含「材料整理」完成标记的系统提醒——
// 该回合负有发起交付物选择卡的职责,stop 钩子据此兜底。扫会话历史而非
// 组装后的 msgs:系统提示词块会撑大 msgs,窗口不稳定。
histMsgs := rc.History()
for i := len(histMsgs) - 1; i >= 0 && i >= len(histMsgs)-8; i-- {
if histMsgs[i].Role != core.RoleSystem {
continue
}
for _, p := range histMsgs[i].Parts {
if p.Type == core.PartText && strings.Contains(p.Text, materialNoticeMarker) {
st.materialNotice = true
break
}
}
if st.materialNotice {
break
}
}
stream = agentllm.WithFallback(stream, ChatFallbacks(eff), st.noteModelSwitch)
defer func() {
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
@@ -123,15 +175,49 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
for _, d := range decls {
name := d.Name
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
return st.runTool(name, string(args))
return st.runToolCtx(ctx, name, string(args))
}})
}
// 内核工作区工具接入直连路径(裁定 1:尽可能开放工具):行号分页 read、
// 精确 edit/multi_edit(先读后写)、grep/glob/list——沙箱=会话工作区。
// 产品侧同域 fs_* 仍按 runtime.fsTools 门控(隐藏),不与内核名冲突。
if sandbox := conversationSandbox(rc); sandbox != "" {
var cpLog *checkpoint.Log
var anchor func() string
if host, ok := rc.(interface {
OpenCheckpoints() (*checkpoint.Log, error)
CheckpointAnchor() string
}); ok {
if lg, err := host.OpenCheckpoints(); err == nil {
cpLog = lg
a := host.CheckpointAnchor()
anchor = func() string { return a }
}
}
ws := agenttools.WorkspaceToolsCheckpointed(sandbox, filestate.New(), cpLog, anchor)
for _, wd := range ws.Decls() {
name := wd.Name
decls = append(decls, agentllm.ToolDecl{Name: wd.Name, Description: wd.Description, Parameters: wd.Parameters})
wsReg := ws
reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result {
res := wsReg.Execute(ctx, name, args)
st.noteKernelResult(ctx, res)
// E4:工作区读仅在 inputs/ 材料读时登记账本(isEvidenceRead;
// 自产文件的读结果不打标,防编辑循环被尾注污染——实测教训)。
if !res.IsError && (evidenceTools[name] || isEvidenceRead(name, args)) && strings.TrimSpace(res.Content) != "" {
if tag, ok := st.tagSource(name, res.Content); ok {
res.Content += "\n【" + tag + "】"
}
}
return res
}})
}
decls = filterPlanDecls(decls, permissionModeOf(rc))
}
var history []agentllm.Message
user := agentllm.Message{Role: agentllm.RoleUser}
if len(msgs) > 0 {
user = msgs[len(msgs)-1]
history = append(history, msgs[:len(msgs)-1]...)
}
wrapped := func(ctx context.Context, model string, messages []agentllm.Message, _ []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
if streamOpts.MaxTokens > 0 {
@@ -167,18 +253,62 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
}
window, reserve, buffer := contextWindow(st.eff)
// 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对——
// JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线
// 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无
// Session(旧行为,不阻断对话)。
// 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)——
// 此前占位只写「见会话记录」,而模型没有读会话记录的工具。
var resultsDir string
sandbox := conversationSandbox(rc)
if sandbox != "" {
resultsDir = filepath.Join(sandbox, ".agent", "results")
}
// 会话转录为权威上下文(质量 P2-I2):内核 Session 承载跨轮历史——
// 完整工具结果/参数/附件原文随转录持久,不再由界面消息按 160 字摘要
// 重建。首轮(或重播种)用 ctxasm 整段组装做一次性种子;此后每轮只传
// 当前用户消息,系统段与动态环境经 Reminders 刷新。
// 结构分歧(排除消息 / 变体切换 / 重新生成)改变指纹 ⇒ 重播种转录,
// 保证转录与消息库一致(regenerate/exclude/候选选择的等价映射)。
// 打开失败降级为无 Session(旧行为:ctxasm 整段组装,不阻断对话)。
var sessStore *agentsession.Store
if sandbox := conversationSandbox(rc); sandbox != "" {
if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil {
seedRun := true
if sandbox != "" {
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
rev := contextRevision(rc.History())
if opened, oerr := agentsession.Open(transcriptPath); oerr == nil {
meta := readSeedMeta(transcriptPath)
switch {
case opened.Stats().Lines == 0:
// 首次播种:本轮回整段组装作为一次性种子并登记指纹。
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
case meta != nil && meta.Revision != rev:
// 结构分歧(排除/变体切换/重新生成):丢弃旧转录,按当前消息库重建。
if rerr := resetTranscript(transcriptPath); rerr == nil {
if reopened, rerr2 := agentsession.Open(transcriptPath); rerr2 == nil {
opened = reopened
}
}
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
log.Printf("[runtime] transcript reseed: rev=%s", rev)
default:
// 正常续跑;或无指纹的既有转录(JournalOnly 时代/崩溃现场):
// 采纳为权威,补登记指纹,不破坏崩溃配对修复。
seedRun = false
if meta == nil {
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
}
}
sessStore = opened
} else {
log.Printf("[runtime] open session transcript: %v", oerr)
}
}
var history []agentllm.Message
if sessStore == nil || seedRun {
// 无转录权威:整段组装(旧行为);或播种轮:完整历史作一次性种子。
history = append(history, msgs[:len(msgs)-1]...)
}
// 转录接近窗口时先做结构化压缩(9 段模板),内核请求侧的微清理兜底。
if sessStore != nil && !seedRun {
st.maybeCompact(sessStore, stream, model, streamOpts, window, reserve, buffer)
}
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
@@ -192,9 +322,11 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
})
res, err := nruntime.Run(rc.Context(), nruntime.Config{
Model: model,
ToolExecution: toolExecMode(eff),
SerialTools: serialTools,
History: history,
Session: sessStore,
JournalOnly: sessStore != nil,
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
Tools: reg,
Stream: wrapped,
MaxTurns: maxTurns,
@@ -205,29 +337,79 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
ContextWindow: window,
OutputReserve: reserve,
ContextBuffer: buffer,
ResultsDir: resultsDir,
OnEvent: st.onEvent,
Hooks: bus,
Reminders: st.reminders,
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
AgentName: agentNameOf(rc.Profile()),
Memory: st.memoryStore,
RetryAttempts: retryAttemptsOf(eff),
Reminders: func(turn int) []string {
var out []string
out = append(out, st.reminders(turn)...)
// 快慢双速入口前置(SR-B10 实路径):带附件的任务在第 0 回合就把
// 双速选项摆给模型——模型对开场指令的遵循远好于中途提醒(实测
// MiniMax 无视中途 system-reminder,但对工作方法开场指令照办)。
// 材料整理入口(附件即保证):系统已确定性启动时注入事实说明
// (职责改为快通道反馈+后续交付物),未预启动(文字+小材料)时
// 保留建议式提示,回合结束由 stop 钩子兜底保证。
if turn == 0 && st.eff.GetNumber("runtime.dualSpeedNudgeSeconds", dualSpeedNudgeDefaultSeconds) > 0 && len(rc.Attachments()) > 0 {
note := ""
if src, ok := rc.(interface{ MaterialNote() string }); ok {
note = strings.TrimSpace(src.MaterialNote())
}
if note == "" {
note = "本任务附带材料,可能属于重任务。若预计要产出正式文档或做深度处理,采用快慢双速:先在当前回复里给用户即时反馈(材料相关性判断、概要或执行计划),再用 delegate_task 的 background=true 把深度工作放到后台执行——收件箱会显示进度卡,完成后系统自动回投结果并汇报。简单任务按常规同步完成即可。"
}
out = append(out, note)
}
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
// 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
if sessStore != nil && !seedRun && turn == 0 {
out = append(out, refresh.Notes...)
if refresh.EnvUpdate != "" {
out = append(out, refresh.EnvUpdate)
}
if sandbox != "" {
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
var ids []string
for _, b := range refresh.TaskBlocks {
ids = append(ids, b.ModuleID)
}
for _, id := range updateRemindedModules(transcriptPath, ids) {
for _, b := range refresh.TaskBlocks {
if b.ModuleID == id {
out = append(out, "本轮适用的补充指引("+b.ModuleID+"):\n"+b.Body)
}
}
}
}
}
return out
},
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
AgentName: agentNameOf(rc.Profile()),
Memory: st.memoryStore,
RetryAttempts: retryAttemptsOf(eff),
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
return st.runTool(call.Name, call.Arguments)
},
}, user)
composed := false
if err == nil && res.Err == nil && len(st.evidence) > 0 && st.eff.GetBool("runtime.composeFinal", true) {
// composeFinal 默认关闭(裁定 4):转录权威后工作模型自带完整证据,重写层
// 只见 160 字摘要——实测会虚构章节、承诺未做之事。开启或异常兜底时才重写。
composeWanted := st.eff.GetBool("runtime.composeFinal", false)
if err == nil && res.Err == nil && len(st.evidence) > 0 && composeWanted {
composed = st.composeFinal(stream, model, res.Notice, streamOpts)
}
if err == nil && res.Err == nil && !composed && res.Notice == "" && strings.TrimSpace(st.turnBody) == "" && len(st.evidence) > 0 {
// 兜底:工作轮结束但没有任何给用户的正文(罕见),才用重写层补一段。
composed = st.composeFinal(stream, model, "", streamOpts)
}
if res.Notice != "" && !composed {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: res.Notice})
}
if err == nil && res.Err == nil && len(st.finalParts) == 0 {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
}
for _, ref := range st.deliverables {
for _, ref := range st.latestDeliverables() {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
}
st.switchMu.Lock()
@@ -256,6 +438,45 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
return nil
}
// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径,
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result) {
call, ok := nruntime.ToolCallFromContext(ctx)
slot := st.slotFor("")
if ok {
slot = st.slotFor(call.ID)
}
if slot == nil {
return
}
summary := summarize(res.Content, nil)
if res.IsError {
summary = "error: " + summary
}
slot.summary = summary
slot.status = statusOf(nil)
if res.IsError {
slot.status = "error"
}
slot.verifyText = tailRunes(res.Content, 600)
}
// slotFor 按调用 ID 取在途槽位;ID 为空且恰有一个槽位时取之(兼容无注入
// 的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
func (st *directRun) slotFor(callID string) *toolSlot {
st.toolMu.Lock()
defer st.toolMu.Unlock()
if callID != "" {
return st.inflight[callID]
}
if len(st.inflight) == 1 {
for _, slot := range st.inflight {
return slot
}
}
return nil
}
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
// 执行开始前的这段时间原先没有任何工具事件,界面只剩光标。
func (st *directRun) noteModelSwitch(from, to string) {
@@ -324,7 +545,9 @@ func (st *directRun) onEvent(ev nruntime.Event) {
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
st.toolMu.Lock()
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
st.toolMu.Unlock()
hasCalls := len(ev.Message.ToolCalls) > 0
if st.turnThink.Len() > 0 {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartThinking, Text: st.turnThink.String(), Process: true, DurationMs: durationMs(st.thinkStart, st.thinkEnd)})
@@ -346,6 +569,10 @@ func (st *directRun) onEvent(ev nruntime.Event) {
if body != "" {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: body, Process: hasCalls})
st.turnBody = body
if !hasCalls {
// 面向用户的答复段落已出现:快通道成立,双速提醒不再需要。
st.replied = true
}
st.textIdx++
}
}
@@ -357,13 +584,19 @@ func (st *directRun) onEvent(ev nruntime.Event) {
if len(st.queued) == 0 {
return
}
call := st.queued[0]
st.queued = st.queued[1:]
st.inflight = &toolSlot{call: call, start: st.l.now()}
call, ok := st.takeQueued(ev.CallID)
if !ok {
return
}
st.toolMu.Lock()
st.inflight[call.ID] = &toolSlot{call: call, start: st.l.now()}
st.toolMu.Unlock()
_ = st.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
case "tool_execution_end":
slot := st.inflight
st.toolMu.Lock()
slot := st.takeInflight(ev.CallID)
if slot == nil {
st.toolMu.Unlock()
return
}
end := st.l.now()
@@ -375,12 +608,38 @@ func (st *directRun) onEvent(ev nruntime.Event) {
st.deliverables = append(st.deliverables, slot.artifact)
}
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
st.toolMu.Unlock()
st.noteFailureOutcome(slot.status)
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
st.inflight = nil
}
}
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
// 材料整理兜底(附件即保证):带附件回合结束仍无后台任务且模型也没自行
// 委派(delegatedBackground 尊重模型判断)→ 系统确定性启动,续跑一轮让
// 模型告知用户。ensureMaterial 幂等 + materialChecked 一次性:不续跑循环。
// 实测动机:MiniMax 对入口提示无视、问一句澄清就收场,0 委派 0 卡片,
// 用户全程无反馈。
if !st.materialChecked && len(st.rc.Attachments()) > 0 && !st.delegatedBackground {
if ens, ok := st.rc.(interface{ EnsureMaterial() string }); ok {
st.materialChecked = true
if note := strings.TrimSpace(ens.EnsureMaterial()); note != "" {
return nruntime.StopDecision{Continue: true, Reason: materialBackstopReason}
}
}
}
// 交付物确认链(I2 混合兜底):用户已在选择卡拍板,但回合结束仍未委派
// 后台任务 → 硬性续跑一轮要求启动(一次性)。
if st.pendingDeliverable != "" && !st.delegatedBackground && !st.deliverableNudged {
st.deliverableNudged = true
return nruntime.StopDecision{Continue: true, Reason: deliverableBackstopReason}
}
// 材料通知回合应急(弱模型不发 confirm_choice):汇报了却没让用户选
// 交付物、也没委派任何后台 → 硬性要求发起选择卡(一次性)。
if st.materialNotice && !st.confirmIssued && !st.delegatedBackground && !st.materialNoticeNudged {
st.materialNoticeNudged = true
return nruntime.StopDecision{Continue: true, Reason: materialNoticeBackstopReason}
}
if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block {
st.demoteLastText()
if st.eff.GetBool("runtime.verify", false) {
@@ -394,14 +653,45 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
if !st.eff.GetBool("runtime.verify", false) {
return nruntime.StopDecision{}
}
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
if reasons := gates.CheckArtifacts(artifactRefsOf(st.deliverables), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
// 终轮检查只评每个文件的最新版本:修订产生新工件后,被替代的旧版本
// 已不是交付物,继续按旧版评分会让检查永远无法通过(实测死循环根因)。
latest := st.latestDeliverables()
// N1 WP5:工件存在/非空/硬事实检查走下沉的 CheckArtifacts(端口在 llm.go 投影;
// P5 后 scorer 只拦 lint error 与 lint 未运行)。
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 {
return st.continueOrNotice(info, strings.Join(reasons, ";"))
}
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
// P5→E3:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料
// (附件解析文本 + 工具证据)。E3 收紧为两轮:第一轮照常给修正机会
// (改值/删除/加「目标/假设」标注——标注后经 MarkedAsEstimate 机械放行);
// 第二轮明确这是最后一次拦截并给出标注出路;两轮后不再拦截(记入检查行
// 如实呈现),续写上限(MaxVerifyContinuations)仍兜底防死循环。
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations {
pairs := st.collectDeliverableText(latest)
if len(pairs) > 0 {
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
if notice, cont := st.factCheckGate(tokens); cont {
// 与交付物门禁/续写上限分支对齐:检查未通过时,现场那段总结归入
// 过程时间线——修正轮之后的收尾才是最终答复(实测 run_b520bf2e3037
// 未降级导致好总结被前端切分折叠,用户只看到修正流水账)。
st.demoteLastText()
st.addVerifyNote("事实核查:" + notice)
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物,然后重新给出更新后的完整最终答复(先结论、再产出物与要点,可注明本次修正)。\n" + notice)}
}
}
}
}
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
if err != nil {
st.addVerifyNote(err.Error())
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
// 验证器没跑成不再是用户可见正文(实测 Notice 会顶掉正式总结)。
// 同源跳过是能力说明,记通过态检查行;真失败记未通过态检查行,
// 过程时间线里都可见,最终答复只留模型自己的收尾。
if strings.Contains(err.Error(), "跳过同源验证") {
st.addCheck(true, verifierUnavailableNotice)
} else {
st.addVerifyNote(err.Error())
}
return nruntime.StopDecision{}
}
if !verdict.Passed {
reason := strings.TrimSpace(verdict.Reason)
@@ -428,6 +718,37 @@ func (st *directRun) demoteLastText() int {
return -1
}
// latestDeliverables 同名工件只保留最后登记的版本,顺序不变。
// 修订(同名再登记)后旧版本不再是交付物:不进终轮检查、不重复出现在
// 回复的交付清单里。无名工件互不合并。
// 超长工具输出的截断转存(runmgr 的 tool-<调用>.txt,kind=data)不是交付物:
// 不进终轮检查、不占交付卡片(实测被评分器当 .txt 交付物误伤,检查连判负)。
func (st *directRun) latestDeliverables() []*core.ArtifactRef {
idx := map[string]int{}
var out []*core.ArtifactRef
for _, ref := range st.deliverables {
if ref == nil || strings.TrimSpace(ref.Name) == "" {
out = append(out, ref)
continue
}
if isToolDumpArtifact(ref) {
continue
}
if i, ok := idx[ref.Name]; ok {
out[i] = ref
continue
}
idx[ref.Name] = len(out)
out = append(out, ref)
}
return out
}
// isToolDumpArtifact 识别「工具结果超长截断转工件」的落盘(上下文救济,非交付物)。
func isToolDumpArtifact(ref *core.ArtifactRef) bool {
return ref.Kind == "data" && strings.HasPrefix(ref.Name, "tool-")
}
func (st *directRun) addVerifyNote(reason string) {
st.addCheck(false, reason)
}
@@ -465,14 +786,24 @@ func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nru
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
}
st.addVerifyNote(reason)
return nruntime.StopDecision{Notice: verifyCapNotice}
// 达到续写上限同样收进检查行(未通过态):让用户看见检查确实没过,
// 而不是用一句系统提示替换掉模型的最终答复。
st.addCheck(false, verifyCapNotice)
return nruntime.StopDecision{}
}
func (st *directRun) runTool(name, args string) agenttools.Result {
call := agentllm.ToolCall{Name: name, Arguments: args}
if st.inflight != nil {
call = st.inflight.call
return st.runToolCtx(context.Background(), name, args)
}
// runToolCtx 执行一个工具调用;ctx 携带内核注入的当前调用(并行执行下按
// CallID 定位在途槽位),无注入时回落唯一在途槽位(旧内核/测试直调)。
func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttools.Result {
call, _ := nruntime.ToolCallFromContext(ctx)
if call.ID == "" {
call = agentllm.ToolCall{Name: name, Arguments: args}
}
slot := st.slotFor(call.ID)
var out string
var err error
switch call.Name {
@@ -500,11 +831,18 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
if sp, ok := st.rc.(interface {
SpawnBackground(agentID, name, task, hint string) (core.ID, error)
}); ok {
id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), a.Task, a.Hint)
// 材料传递(R2):模型自派的后台任务同样带上材料指引——子代理
// 看不到父回合内联的附件全文,需指明检索通道。
task := a.Task
if len(st.rc.Attachments()) > 0 {
task += delegateMaterialsNote(st.rc.Attachments())
}
id, serr := sp.SpawnBackground(subagentProfileID(a.Agent), orDefault(a.Name, "子代理"), task, a.Hint)
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
out = fmt.Sprintf("已在后台启动 %s,完成后会自动通知", id)
st.delegatedBackground = true
out = fmt.Sprintf("已在后台启动 %s(慢通道):收件箱已建进度卡,完成后结果回投该卡并自动汇报;先把快通道的即时反馈写给用户,不要等待", id)
}
break
}
@@ -521,9 +859,62 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
if serr != nil {
out = "子代理失败:" + serr.Error()
} else {
out = orDefault(res.Summary, "子代理完成")
// F1 修复:回传子代理结论全文(capSubagentResult 已做 32k 截断与
// 超长落盘指针),Summary 只是 160 字摘要——只回摘要会让父代理在
// 摘要的摘要上凭空补细节。
out = orDefault(res.Text, orDefault(res.Summary, "子代理完成"))
st.pinSubagentArtifacts(res.Summary + "\n" + res.Text)
}
case toolConfirmChoice:
var a struct {
Question string `json:"question"`
Options []struct {
ID, Label, Description, Impact string
Recommended bool
} `json:"options"`
FreeInput *bool `json:"free_input"`
TimeoutSec int `json:"timeout_sec"`
}
if uerr := json.Unmarshal([]byte(call.Arguments), &a); uerr != nil || strings.TrimSpace(a.Question) == "" || len(a.Options) < 2 {
out = "confirm_choice 参数无效:需要 question 与 2-4 个选项(id/label/description/impact)"
break
}
card := core.HITLCard{Kind: core.HITLKindChoice, Question: strings.TrimSpace(a.Question), AllowFreeInput: a.FreeInput == nil || *a.FreeInput}
for _, o := range a.Options {
choice := core.HITLChoice{ID: strings.TrimSpace(o.ID), Label: strings.TrimSpace(o.Label), Description: o.Description, Impact: o.Impact, Recommended: o.Recommended}
if choice.ID == "" {
choice.ID = choice.Label
}
if strings.TrimSpace(choice.Impact) == "" {
choice.Impact = "按选项说明执行"
}
card.Choices = append(card.Choices, choice)
}
if a.TimeoutSec > 0 {
secs := a.TimeoutSec
card.TimeoutSec = &secs
}
st.confirmIssued = true
resp, herr := st.rc.AskHITL(card)
if herr != nil {
out = "选择等待结束(未获得答复):" + herr.Error() + "。请在回复里列出可选项,请用户直接答复。"
break
}
if id := strings.TrimSpace(resp.ChoiceID); id != "" {
label := id
for _, c := range card.Choices {
if c.ID == id {
label = c.Label
}
}
st.pendingDeliverable = label
out = "用户已选择:" + label + "。立即用 delegate_task 的 background=true 启动对应后台任务(任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点),启动后给用户一句确认反馈即可,不要等待完成。"
} else if free := strings.TrimSpace(resp.FreeText); free != "" {
st.pendingDeliverable = free
out = "用户自由输入:" + free + "。若这是正式交付物要求,立即用 delegate_task 的 background=true 启动对应后台任务(描述结合材料整理结果);若是澄清或追问,先如实答复,再按需推进。"
} else {
out = "用户未给出明确答复。请在回复里列出可选项,请用户直接答复。"
}
case toolReview:
out = st.reviewFindings(call.Arguments)
case toolPlan:
@@ -563,8 +954,16 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
} else {
out = res.Output
}
if res.Artifact != nil && st.inflight != nil {
st.inflight.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
// E4:R4 空命中计数 + 证据账本登记(成功且非空的证据类工具输出
// 尾注【src_N|工具】来源标记——数字溯源从此有可指地址)。
st.noteEvidenceCall(call.Name, res.Status, out)
if res.Status == "ok" && evidenceTools[call.Name] && strings.TrimSpace(out) != "" {
if tag, ok := st.tagSource(call.Name, out); ok {
out += "\n【" + tag + "】"
}
}
if res.Artifact != nil && slot != nil {
slot.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
if st.cardflow && res.Truncated && !st.explicitCard {
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
if ferr == nil {
@@ -572,8 +971,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
}
}
}
if st.inflight != nil {
st.inflight.truncated = res.Truncated
if slot != nil {
slot.truncated = res.Truncated
}
}
@@ -583,13 +982,13 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
summary = "error: " + err.Error()
out = summary
}
if st.inflight != nil {
st.inflight.summary = summary
st.inflight.status = status
st.inflight.verifyText = tailRunes(out, 600)
if slot != nil {
slot.summary = summary
slot.status = status
slot.verifyText = tailRunes(out, 600)
}
if len(out) > 16000 {
out = out[:16000] + "…(超长截断)"
if r := []rune(out); len(r) > 16000 {
out = string(r[:16000]) + "…(超长截断)"
}
return agenttools.Result{Content: out, IsError: err != nil}
}
@@ -777,3 +1176,65 @@ func errString(err error) string {
}
return err.Error()
}
// ---- 并行工具执行的辅助(E1/F5)----
// serialTools 顺序敏感或带会话级副作用的工具:作为内核 SerialTools 传入——
// 并行执行下这些调用按模型声明顺序逐个执行(互斥且保序;只互斥不保序会让
// 流式卡 card_begin→append→commit 乱序,实测踩过)。卡片流四步有先后依赖,
// 计划/待办是整表更新,工作流与小队编排派生 run,执行原语与文件编辑共享会话
// 工作区。读检索类与 delegate_task/review_findings 并行。
var serialTools = map[string]bool{
toolPlan: true, toolTodos: true,
toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true,
toolRunWorkflow: true, toolSquad: true, toolConfirmChoice: true,
"run_shell": true, "run_python": true, "register_artifact": true,
"edit": true, "multi_edit": true,
}
// toolExecMode 同回合多工具调用的执行模式:runtime.parallelTools(缺省开)
// 为 parallel(顺序敏感工具经 Config.SerialTools 由内核保序),关闭时空串
// 回落内核缺省 sequential。
func toolExecMode(eff core.EffectiveSettings) string {
if eff.GetBool("runtime.parallelTools", true) {
return nruntime.ExecParallel
}
return ""
}
// takeQueued 按内核事件 CallID 取出队列中对应调用(并行下 start 事件乱序到达);
// CallID 为空(旧内核/未知来源)时按原先进先出。调用方须持有 toolMu。
func (st *directRun) takeQueued(callID string) (agentllm.ToolCall, bool) {
if callID != "" {
for i, c := range st.queued {
if c.ID == callID {
st.queued = append(st.queued[:i], st.queued[i+1:]...)
return c, true
}
}
return agentllm.ToolCall{}, false
}
if len(st.queued) == 0 {
return agentllm.ToolCall{}, false
}
c := st.queued[0]
st.queued = st.queued[1:]
return c, true
}
// takeInflight 按事件 CallID 取出并删除在途槽位;CallID 为空且恰有一个在途
// 槽位时取之(兼容旧内核)。调用方须持有 toolMu。
func (st *directRun) takeInflight(callID string) *toolSlot {
if callID != "" {
slot := st.inflight[callID]
delete(st.inflight, callID)
return slot
}
if len(st.inflight) == 1 {
for _, slot := range st.inflight {
clear(st.inflight)
return slot
}
}
return nil
}
+103
View File
@@ -0,0 +1,103 @@
package runtime
import (
"strings"
"testing"
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// ---- F1:delegate_task 回传子代理全文,不再只有 160 字摘要 ----
func TestDelegateToolReturnsFullText(t *testing.T) {
full := strings.Repeat("子代理结论细节。", 60) // 480 字,远超 160
f := &fakeRunCtx{spawnAgentFn: func(agentID, name, task, hint string) (core.AgentResultPayload, error) {
return core.AgentResultPayload{Status: core.RunCompleted, Text: full, Summary: "完成(160字内摘要)"}, nil
}}
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
res := st.runTool("delegate_task", `{"name":"调研","task":"查证X"}`)
if res.IsError {
t.Fatalf("delegate failed: %s", res.Content)
}
if !strings.Contains(res.Content, "子代理结论细节。子代理结论细节。子代理结论细节。") {
t.Fatalf("delegate result must carry the full child text, got: %.120s…", res.Content)
}
if strings.Contains(res.Content, "160字内摘要") {
t.Fatalf("summary must be fallback only, got: %.120s…", res.Content)
}
}
// ---- F5:并行执行下工具事件按 CallID 配对 ----
func TestToolEventsPairByCallID(t *testing.T) {
f := &fakeRunCtx{}
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
st.queued = []agentllm.ToolCall{
{ID: "c1", Name: "knowledge_search", Arguments: `{}`},
{ID: "c2", Name: "web_search", Arguments: `{}`},
}
// 乱序进出:c1 先开始,c2 先结束(并行下的真实时序)。
st.onEvent(nruntime.Event{Type: "tool_execution_start", Tool: "knowledge_search", CallID: "c1"})
st.onEvent(nruntime.Event{Type: "tool_execution_start", Tool: "web_search", CallID: "c2"})
st.onEvent(nruntime.Event{Type: "tool_execution_end", Tool: "web_search", CallID: "c2"})
st.onEvent(nruntime.Event{Type: "tool_execution_end", Tool: "knowledge_search", CallID: "c1"})
if len(st.inflight) != 0 {
t.Fatalf("inflight must be empty after both ends: %v", st.inflight)
}
if len(st.finalParts) != 4 {
t.Fatalf("finalParts = %d (want 4: call+result ×2)", len(st.finalParts))
}
// 结束顺序决定部件顺序:先 c2 后 c1。
if got := st.finalParts[0].ToolCall.ID; got != "c2" {
t.Fatalf("first tool part = %s (want c2)", got)
}
if got := st.finalParts[1].ToolResult.CallID; got != "c2" {
t.Fatalf("first result part = %s (want c2)", got)
}
if got := st.finalParts[2].ToolCall.ID; got != "c1" {
t.Fatalf("second tool part = %s (want c1)", got)
}
if got := st.finalParts[3].ToolResult.CallID; got != "c1" {
t.Fatalf("second result part = %s (want c1)", got)
}
}
// ---- F5:执行模式与串行集合 ----
func TestToolExecModeAndSerialSet(t *testing.T) {
if got := toolExecMode(core.EffectiveSettings{}); got != nruntime.ExecParallel {
t.Fatalf("default must be parallel, got %q", got)
}
off := core.EffectiveSettings{"runtime": map[string]any{"parallelTools": false}}
if got := toolExecMode(off); got != "" {
t.Fatalf("parallelTools=false must fall back to sequential, got %q", got)
}
// 顺序敏感工具必须串行;可并行的读检索与委派不得误入。
for _, name := range []string{"update_plan", "update_todos", "submit_card", "card_begin", "card_append", "card_commit", "run_workflow", "squad_orchestrate", "run_shell", "run_python", "register_artifact"} {
if !serialTools[name] {
t.Fatalf("serialTools missing %s", name)
}
}
for _, name := range []string{"delegate_task", "review_findings", "knowledge_search", "web_search", "fs_read_file", "write_artifact"} {
if serialTools[name] {
t.Fatalf("serialTools must not contain %s", name)
}
}
}
// ---- F2:MiniMax 前缀自动置位 reasoning_split ----
func TestOpenLLMClientReasoningSplitAuto(t *testing.T) {
if c := openLLMClient("http://x", "k", "MiniMax-M2.7-highspeed"); !c.ReasoningSplit {
t.Fatal("MiniMax model must set ReasoningSplit")
}
if c := openLLMClient("http://x", "k", "MiniMax-M3"); !c.ReasoningSplit {
t.Fatal("MiniMax M3 must set ReasoningSplit")
}
if c := openLLMClient("http://x", "k", "claude-sonnet-5"); c.ReasoningSplit {
t.Fatal("non-MiniMax model must not set ReasoningSplit")
}
}

Some files were not shown because too many files have changed in this diff Show More