Compare commits

...
142 Commits
Author SHA1 Message Date
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
weichen f1c9cc0644 Merge pull request 'Keep DOCX tracked-change/hyperlink text and add an exec hard switch' (#110) from feature/v020-docx-and-exec-switch into main
ci / vet-and-test (push) Successful in 43s
2026-10-09 10:39:18 +00:00
weichen 68c1a7272a Keep tracked-change and hyperlink text in DOCX and add an exec hard switch
ci / vet-and-test (pull_request) Successful in 43s
Why: The DOCX parser only read w:r runs that were direct children of w:p, so
text inside w:ins, w:hyperlink and w:fldSimple was dropped with no warning,
violating the no-silent-degradation rule. Separately, runtime.exec is a
user-writable setting, so multi-user deployments could not reliably turn the
exec primitives off.
Evidence: SELF - red tests TestParseDocxTrackedChangesAndHyperlinks and
TestExecHardDisabledOverridesSettings failed before and pass after; backend
go test ./... (32 packages) passes; a mock-mode binary started with
--disable-exec-tools hides run_shell/run_python/register_artifact from
/api/tools even after the user sets runtime.exec=true.
Impact: DOCX paragraphs are decoded from the token stream at any depth;
deleted/moved-from text, field codes and mc:Fallback are excluded and
revised documents carry an explicit warning. New Options.DisableExecTools
(SurfaceVersion 2.5.0-experimental), CLI --disable-exec-tools, config key
disableExecTools and env AGENT_SYSTEM_DISABLE_EXEC_TOOLS; zero value keeps
current behavior.
Refs: Agent-System-RDM docs/proposals/2026-10-09-V22-review-and-implementation-plan.md (G0-7, B-5)
2026-10-09 10:36:02 +00:00
weichen 399e2e53e6 Merge pull request 'Close the EXT-8 remaining blocks: vector sharing, hostapi face, citation consistency' (#109) from feature/ext8-remaining-shared-reads into main
ci / vet-and-test (push) Successful in 38s
2026-10-08 17:22:46 +00:00
weichen 92ede3b970 Close the EXT-8 remaining blocks: vector sharing, hostapi face, citation consistency
ci / vet-and-test (pull_request) Successful in 37s
Why: EXT-8 closure needs the vector channel shared (not just fulltext), the /host/v1 knowledge face honoring the authorizer, and citation-ref-to-artifact consistency pinned as an acceptance test.
Evidence: TestRetrieveSharedVectorChannel (hash embedder: authorized shared vector hit retrievable, denied yields zero), TestSharedSearchFace (hostapi switches to SharedSearch when wired), TestSharedCitationRefDownloadable (shared hit -> Ref -> artifact content download for the authorized member); go test ./... 32/32 packages ok.
Impact: store.SearchKnowledgeVectorsShared; rag.Pipeline.RetrieveShared (own + shared fulltext + shared vector, RRF-merged); server searchKnowledge prefers RetrieveShared when the retriever implements it; hostapi Server.SharedSearch wired in the composition root through the same authorizer slot. Default behavior unchanged.
Refs: EXT-8 proposal acceptance; slices 1-2.
2026-10-08 17:16:10 +00:00
weichen 46c2abbca8 Merge pull request 'Wire the EXT-11 scoped-host factory through the composition root' (#108) from feature/ext-11-scoped-host-factory into main
ci / vet-and-test (push) Successful in 39s
2026-10-08 17:08:07 +00:00
weichen bbe39b475f gofmt the ext-11 files
ci / vet-and-test (pull_request) Successful in 42s
Why: The implementation commit left two files unformatted; keep the tree gofmt-clean.
Evidence: gofmt -l now empty; targeted test still green.
Impact: Formatting only.
Refs: Follow-up to the wiring commit.
2026-10-08 17:02:16 +00:00
weichen 6162817ea3 Wire the scoped-host factory through the composition root
Why: The red run pinned the missing wiring; downstream recovery paths need a legal handle outside dispatch.
Evidence: TestScopedHostFactory green (fail-closed before wiring; Owner/PluginID, KV roundtrip, prefix enforcement, empty-arg rejection after); go test ./... all packages ok.
Impact: wireScopedHostFactory injected in app.Run after hostcaps assembly; no dispatch-path change.
Refs: EXT-11; red run at 182d8b5.
2026-10-08 17:01:51 +00:00
weichen 182d8b5143 Add the EXT-11 scoped-host surface with a red factory test
Why: EXT-11 is accepted; the surface (SetScopedHostFactory/NewScopedHost, SurfaceVersion 2.4.0-experimental) must land with the behavioral red pinned - wiring absent, NewScopedHost fails closed with ErrNotLinked.
Evidence: TestScopedHostFactory red at the wired-factory assertion; row follows on the task card.
Impact: pkg/extension additive surface (surface.txt +2 symbols); internal wireScopedHostFactory stub. Behavior unchanged.
Refs: EXT-11 proposal; task card.
2026-10-08 17:00:53 +00:00
weichen 8a9a354649 Merge pull request 'Implement shared knowledge retrieval, card mirror switch, and SSE project gate (EXT-8 slice 2)' (#107) from feature/ext8-slice2-shared-retrieval into main
ci / vet-and-test (push) Successful in 41s
2026-10-08 16:24:19 +00:00
weichen 1de663a8d3 Implement shared knowledge retrieval, card mirror switch, and SSE project gate
ci / vet-and-test (pull_request) Successful in 40s
Why: The three pinned reds define EXT-8 slice 2 - adapter-granted cross-owner knowledge must be retrievable, conversation cards must stay out of public project streams when the host opts out, and project-stream subscription must honor the authorizer.
Evidence: TestConvCardMirrorSwitch, TestSharedKnowledgeRetrieval, TestProjectSSEScopeGate green; go test ./... 32/32 packages ok; rows follow on the task card.
Impact: store gains predicate-based SearchKnowledgeShared (FTS/LIKE shared variants + PG); searchKnowledge appends adapter-authorized hits; cardsvc emit gate (no publish = no persistence = clean replay); events project scope consults authorizeSharedProject. Default behavior unchanged everywhere.
Refs: EXT-8/EXT-12 proposals; red run at surface commit.
2026-10-08 16:18:07 +00:00
weichen d3381b208b Add the EXT-12 mirror switch surface and slice-2 red tests
Why: Knowledge retrieval cross-owner and conversation-card mirroring are the registered EXT-8 slice-2 items; the fields/wiring land with the acceptance reds pinned first.
Evidence: mirror red (project channel still emitted with switch off); shared-retrieval red (grant yields 0 hits); SSE-scope red (grant yields non-200); rows follow on the task card.
Impact: Options.DisableConvCardMirror passthrough (extension/link/app); cardsvc.MirrorConvCards (default true = current behavior); SurfaceVersion 2.3.0-experimental. Behavior unchanged.
Refs: EXT-8/EXT-12 proposals; task card.
2026-10-08 16:08:20 +00:00
weichen c5575aab27 Merge pull request 'Implement EXT-9 idempotent knowledge publish and revoke with the upload index switch' (#106) from feature/ext-9-index-publish into main
ci / vet-and-test (push) Successful in 40s
2026-10-08 16:01:50 +00:00
weichen fbc924c240 Implement EXT-9 idempotent publish, prefix-enforced revoke, and the upload index switch
ci / vet-and-test (pull_request) Successful in 41s
Why: The red acceptance tests pin the EXT-9 semantics - held material must not enter retrieval until published, revoke must stop retrieval immediately and idempotently, manual sources must stay out of plugin reach.
Evidence: TestKnowledgePublishIdempotentAndRevoke and TestUploadPreIndexSwitch green; go test ./... 32/32 packages ok; rows follow on the task card.
Impact: hostcaps KnowledgePublish (per-ref row+vector cleanup then existing write path) and KnowledgeDelete (plugin:<id> prefix enforced); store DeleteKnowledgeBySource with vector cleanup; upload indexing gated by DisableUploadIndexing (default false = unchanged).
Refs: EXT-9 proposal; red run at surface commit.
2026-10-08 15:55:02 +00:00
weichen 1ee32822c3 Add the EXT-9 publish/revoke surface and red acceptance tests
Why: EXT-9 is the other step-4 hard prerequisite; the surface (Options.DisableUploadIndexing passthrough, Host.KnowledgePublish idempotent replace, Host.KnowledgeDelete prefix-enforced revoke, SurfaceVersion 2.2.0-experimental) must land with the acceptance reds pinned before implementation.
Evidence: app red = publish not implemented; server red = switch-on still indexes (hits=1); rows recorded on the task card.
Impact: pkg/extension additive surface (surface.txt updated); internal Options passthrough; Server.DisableUploadIndexing field; host method stubs. Behavior unchanged.
Refs: EXT-9 proposal; task card.
2026-10-08 15:53:39 +00:00
weichen 251c204a6e Merge pull request 'Consult the EXT-8 resource authorizer in artifact read paths' (#105) from feature/ext-8-resource-authorizer into main
ci / vet-and-test (push) Successful in 42s
2026-10-08 15:42:12 +00:00
weichen a4783d18e7 Consult the EXT-8 authorizer in project artifact read paths
ci / vet-and-test (pull_request) Successful in 41s
Why: With the surface and injection seam in place, the read endpoints must fall back to the adapter when owner-scoped lookup misses, keeping default-deny and fail-closed semantics.
Evidence: TestProjectResourceAuthorizerSharesReads green (default deny, shared list/meta/content via adapter, third-party denied, authorizer error fail-closed); go test ./... 32/32 packages ok.
Impact: listArtifacts/getArtifact/artifactContent consult authorizeSharedProject; store gains gated helpers (GetArtifactByID/ArtifactContentByID/ListProjectArtifactsAll) documented internal-only; writes untouched; SSE project stream and project card aggregate deliberately deferred to EXT-12 (conversation-card mirroring privacy).
Refs: EXT-8; red run at d89e748; task card.
2026-10-08 15:34:18 +00:00
weichen d89e74880b Add the EXT-8 authorizer surface and a red sharing test
Why: EXT-8 is accepted (Documents #49); the surface (Registry.RegisterResourceAuthorizer, ResourceAuthorizer, SurfaceVersion 2.1.0-experimental) plus the Server injection seam must land before the read-path consultation, and the behavioral red run proves the gap.
Evidence: go test ./internal/server -run TestProjectResourceAuthorizerSharesReads FAIL (shared read 404 with adapter set); default-deny section passes; row recorded on the task card.
Impact: pkg/extension additive surface (surface.txt updated); projectAuthz slot in hostcaps; Server.ProjectAuthz + setter; app wiring after compiled load; declarative registry fails closed. Read behavior unchanged.
Refs: EXT-8 in Documents 2026-10-08-ext-c1-c7-rdm-shared-contracts; task card.
2026-10-08 15:32:04 +00:00
weichen e0b1c27f1f Scope command audit storage and queries to the requesting owner
ci / vet-and-test (pull_request) Successful in 42s
Why: GET /api/commands/audit leaked every tenant's audit rows (command ids, entrances, conversation ids, param digests) to any authenticated user; owner was never recorded nor filtered.
Evidence: Red run at a7cc69b FAIL (alice sees bob's row); after fix go test ./... 32/32 packages ok, including TestCommandAuditOwnerScoping and the v18 backfill test (rows recorded on the task card).
Impact: v18 migration adds command_audit.owner with actor-based backfill (empty-actor rows stay invisible via API, kept in-table); AppendAudit/ListAudits gain owner params; cmdbus port and fakes updated; handler passes authenticated owner. No public extension surface change.
Refs: Task card R1; U0 proposal section 5 C7 finding.
2026-10-08 14:02:19 +00:00
weichen a7cc69b61e Add a red test for cross-tenant command audit leakage
Why: GET /api/commands/audit returns every tenant's audit rows; the regression test pins the desired owner-scoped behavior before the fix.
Evidence: go test ./internal/server -run TestCommandAuditOwnerScoping FAIL (owner alice sees bob's row; output recorded on the task card).
Impact: Test only; suite red on this commit by design.
Refs: Task card R1; U0 proposal section 5 C7 security finding.
2026-10-08 13:57:30 +00:00
weichen fe7c245f5c Merge pull request 'Point the nested squad pin at the a2a adapter and pin alignment' (#103) from feature/q3-squad-pin into main
ci / vet-and-test (push) Successful in 44s
Merge pull request 'Point the nested squad pin at the a2a adapter and pin alignment' (#103) from feature/q3-squad-pin into main
2026-10-08 04:03:24 +00:00
weichen aa03de4045 Point the nested squad pin at the a2a adapter and pin alignment
ci / vet-and-test (pull_request) Successful in 43s
Why: Dev-level pin consistency (check-pins hard-errors on nested drift);
squad main advanced to 819b0c5 (a2a adapter module #47, StepSpec.Brief #46,
nested agent-native pin #48).

Evidence: full backend suite green against 819b0c5 (SELF); CI authoritative
(GATE).

Impact: nested deps/agent-squad 633e853 -> 819b0c5; no backend source
changes (backend consumes squad via wfcompile/squadadapter surfaces which
are unchanged since #99-#102).

Refs: Q3 plan WP4/WP6; Dev card .task/feature-agent-stack-q3.md
2026-10-08 04:01:20 +00:00
weichen 4791b2e760 Merge pull request 'Add the task-level A/B dual-run harness with demo.brief evidence (Q3 WP5)' (#102) from feature/q3-ab-harness into main
ci / vet-and-test (push) Successful in 47s
Merge pull request 'Add the task-level A/B dual-run harness with demo.brief evidence (Q3 WP5)' (#102) from feature/q3-ab-harness into main
2026-10-08 03:49:31 +00:00
weichen 196c8c09f7 Add the task-level A/B dual-run harness with demo.brief evidence (Q3 WP5)
ci / vet-and-test (pull_request) Successful in 42s
Why: Q3 WP5 requires a same-batch dual-run harness (direct vs squad
compiled graph) that records differences without demanding byte-identical
output, feeding DecideSwitch evidence for the runtime.entry default-switch
decision.

Evidence: eval/ab_test.go mechanics (theme-based scoring, status/theme/
latency diff notes, P50, DecideSwitch wiring, RunFunc error tolerance,
empty batch); wfcompile TestABDemoBriefDualRun runs the demo.brief scenario
on both entries (direct = scripted single-agent answer; squad = real
RunCompiled with stub backend and judge) and logs the report: both sides
100/100, summary-form difference recorded, decision '切换条件已满足…合入前
需要产品确认'. Full backend suite green (SELF); CI authoritative (GATE).

Impact: new eval/ab.go harness (pure mechanics, no runtime dependency);
no product default is flipped - runtime.entry stays direct until product
confirmation per the DecideSwitch report semantics (evidence recorded in
the task card and PR).

Refs: Q3 plan WP5 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q3.md
2026-10-08 03:46:54 +00:00
weichen 2e42ed83f0 Merge pull request 'Complete the delegation task brief with output format and tool sections (Q3 WP3)' (#101) from feature/q3-delegation-template into main
ci / vet-and-test (push) Successful in 1m15s
Merge pull request 'Complete the delegation task brief with output format and tool sections (Q3 WP3)' (#101) from feature/q3-delegation-template into main
2026-10-08 03:33:06 +00:00
weichen 7642dcec24 Complete the delegation task brief with output format and tool sections (Q3 WP3)
ci / vet-and-test (pull_request) Successful in 48s
Why: Q3 acceptance requires the delegation template to state goal, output
format, tools and boundaries; SubagentFactory's brief had four of six
sections and the kernel Request gained the two carriers (agent-native #40).

Evidence: adapter_test task-brief assertions extended (输出格式/工具面
sections present with declared values); full backend suite green (SELF);
CI authoritative (GATE).

Impact: nested deps/agent-native 1ac21c6 -> 37c5e6f (Request.OutputFormat/
Tools + delegate_task schema); SubagentFactory renders the two sections
when declared, omits them when empty (no behavior change for existing
callers).

Refs: Q3 plan WP3; task card .task/feature-agent-stack-q3.md
2026-10-08 03:31:03 +00:00
weichen 76b9cf36a8 Merge pull request 'Retire the legacy workflow executor and switch squad steps to agent-native (Q3 WP2)' (#100) from feature/q3-wp2-retire into main
ci / vet-and-test (push) Successful in 50s
Merge pull request 'Retire the legacy workflow executor and switch squad steps to agent-native (Q3 WP2)' (#100) from feature/q3-wp2-retire into main
2026-10-08 03:25:44 +00:00
weichen 70afe40b00 Retire the legacy workflow executor and switch squad steps to agent-native (Q3 WP2)
ci / vet-and-test (pull_request) Successful in 47s
Why: Q3 WP2 retires the SpawnSubagent-compression execution path: all
WorkflowDef execution now goes through the wfcompile compiled graph, and
squad steps run on the agent-native kernel backend instead of the
agent-system step backend.

Evidence: runtime-level P4 parity tests (workflow_compiled_test.go):
registered branch lookup/errors, planned branch confirmation gate +
dynamic 8-step cap fail-closed before any HITL, model script switch
default-off, missing-slot HITL collection with compile-time rendering,
judge stream routed to the configured lightweight model (nil = fail-closed
verifier-unavailable). squadadapter TestOrchestrateRunsOnKernelStub:
four-phase skeleton runs end-to-end on the kernel stub (write_file
materialization, seeded skills, exec allowlist). server TestSquadOrchestration
reworked: kernel steps run in-process (no subagent child runs). Full
backend suite green; wfcompile/squadadapter/runmgr green with -race
(pre-existing llm_test fake race on main recorded as debt, CI does not
gate -race).

Impact: DELETED runtime/workflow.go + workflow_script.go (legacy step loop,
critic, script host) and their tests; squadadapter stepBackend
'agent-system' + profileForRole deleted; BuildBackends exported and
registers only agent-native; Orchestrate DefaultBackend -> agent-native;
builtin roles declare agent-native with write_file added to allowlists and
writer run_shell -> run_command (kernel tool name); runmgr default workflow
runner -> CompiledWorkflowRunner; mock #workflow demo runs compiled
(host-only steps). Model script branch keeps the single script-level
confirmation (no extra plan confirmation), matching legacy.

Refs: Q3 plan WP2 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q3.md
2026-10-08 03:24:30 +00:00
weichen a1aba1527a Merge pull request 'Add the WorkflowDef-to-squad compiler with host intent steps (Q3 WP1)' (#99) from feature/q3-wfcompile into main
ci / vet-and-test (push) Successful in 42s
Merge pull request 'Add the WorkflowDef-to-squad compiler with host intent steps (Q3 WP1)' (#99) from feature/q3-wfcompile into main
2026-10-08 02:38:39 +00:00
weichen 017c96baa5 Add the WorkflowDef-to-squad compiler with host intent steps (Q3 WP1)
ci / vet-and-test (pull_request) Successful in 44s
Why: Q3 WP1 requires core.WorkflowDef workflows to run as compiled squad
graphs (P4 limits and DeniedTools enforced at the compile entry) so the
legacy SpawnSubagent-compression executor can be retired in WP2.

Evidence: internal/wfcompile conformance (zero model, zero network; script
step runs the real codeorch host like the legacy tests): demo.brief shape
(hitl/prompt+critic/artifact/card mapping, evaluator-optimizer loop budget
= maxRetries+1, needs chain), P4 limits (dynamic >8 rejected, dynamic
script rejected, DeniedTools, critic >3, workflow retries >6), demo.brief
E2E (critic fail->retry->pass, drops record, artifact from step-ref file,
card, hitl sequence without plan confirmation for registered workflows,
BB out_length visible in the draft task brief), critic exhaustion fails
closed, demo.script E2E (plugin script exempt from confirmation, wf.tool/
ask/artifact/step callbacks), workflowScripts off fails, model script
double confirmation with reject paths. Full backend suite green (SELF);
CI authoritative (GATE).

Impact: new package internal/wfcompile (compiler + host IntentRunner +
RunCompiled driver); nested deps/agent-squad 06934bc -> 633e853 (StepSpec
.Brief). No existing backend behavior changes (legacy executor untouched
until WP2).

Refs: Q3 plan WP1 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q3.md
2026-10-08 02:36:39 +00:00
weichen 06934bccab Merge pull request 'Point the nested squad pin at the Q2 topology library completion' (#98) from feature/q2-squad-pin into main
ci / vet-and-test (push) Successful in 43s
Merge pull request 'Point the nested squad pin at the Q2 topology library completion' (#98) from feature/q2-squad-pin into main
2026-10-08 01:35:07 +00:00
weichen 30bbf5483f Point the nested squad pin at the Q2 topology library completion
ci / vet-and-test (pull_request) Successful in 42s
Why: Q2 (topology library) landed in Agent-Squad main across WP1-4
(12 builtin topologies with scripted-backend conformance); backend consumes
squad via squadadapter and must track the completed surface.

Evidence: GOWORK=off go build ./... and go test ./... -count=1 green against
squad bcc8d08 (SELF); backend CI on the PR head is authoritative (GATE).

Impact: nested deps/agent-squad c462bc4 -> bcc8d08 (additive squad API:
topology registry, RunOptions knobs, mailbox/handoff/board state); no
backend source changes required.

Refs: Q2 plan (2026-10-07-agent-stack-q2q3-plans.md); Dev task card
.task/feature-agent-stack-q2.md
2026-10-08 01:32:45 +00:00
weichen 6e02a1f574 Merge pull request 'Point the nested core pin at the TypeScript contract authority update' (#97) from feature/n3-core-pin into main
ci / vet-and-test (push) Successful in 41s
Point the nested core pin at the TypeScript contract authority update
2026-10-07 18:05:37 +00:00
weichen 26bd9286ad Make the rewind happy test tolerant of the settle-release window
ci / vet-and-test (pull_request) Successful in 41s
Why: run terminal status persists before the conversation claim is
released; on the slow CI runner the rewind POST landed inside that
window and got a 409. Retry the POST until the claim frees (bounded).
Evidence: go test -count=5 ./internal/server/ -run TestRewindFilesHTTPHappy
green; full suite green.
Impact: test-only.
Refs: Agent-System-Backend#94 #97
2026-10-07 18:04:08 +00:00
weichen 31a4e7576a Point the nested core pin at the TypeScript contract authority update
ci / vet-and-test (pull_request) Failing after 40s
Why: Dev pins modules/core at a253a40b (the TS contract authority);
the combination check requires the nested pin to follow.
Evidence: tree-only pin move.
Impact: none.
Refs: Agent-System-Core#24
2026-10-07 17:55:04 +00:00
weichen e59b25c816 Merge pull request 'Split usage accounting end to end, retry by default, and take the WP6 pins (N3 WP6)' (#96) from feature/n3-usage-retry-wp6 into main
ci / vet-and-test (push) Successful in 41s
Split usage accounting end to end, retry by default, and take the WP6 pins (N3 WP6)
2026-10-07 17:19:27 +00:00
weichen 8c5612a0a3 Split usage accounting end to end, retry by default, and take the WP6 pins (N3 WP6)
ci / vet-and-test (pull_request) Successful in 44s
Why: subagent runs recorded zero tokens, cache hits were invisible in
run records, the usage event skipped terminal paths, and stream retry
existed only on the direct-chat path (audit N20 residual).
Evidence: go test -count=1 ./... green; TestRetryAttemptsMapping
(setting 0=off→-1, N→N), TestDirectRunUsageSplitOnRecord (Last* and
cache land in the run usage), TestSubRunAccountsTokens (subagent
accounting), TestAddUsageAccumulatesSplitFields (handle chain); pins
carry Agent-Native#35/#37/#38/#39, Agent-Squad#41, Core Usage fields.
Impact: run.Usage carries Last*/cache on every path; subagent runs are
accounted; runtime.llmRetries maps onto the library default (setting 0
disables via -1); the verifier wraps retry+fallback explicitly; retry
events surface as process parts.
Refs: Agent-Native#35-#39, Agent-Squad#41, N3 plan WP6
2026-10-07 17:17:56 +00:00
weichen e5034e177d Merge pull request 'Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5)' (#95) from feature/n3-memory-wp5 into main
ci / vet-and-test (push) Successful in 41s
Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5)
2026-10-07 16:24:58 +00:00
weichen 766a96e817 Wire the memory port: store-backed adapter, loop injection, migration parity (N3 WP5)
ci / vet-and-test (pull_request) Successful in 41s
Why: memory recall lived as a product-side interface assertion with no
library port, AgentSpec.MemoryScope had no consumer, and the model had
no write-back path. The port (Agent-Native#34) now exists; the product
adapts it over memory_entries.
Evidence: go test -count=1 ./... green; TestStoreMemoryRecallScopeAndLimit
(scope filters and limit), TestStoreMemoryAppendRoundtrip (project
context inheritance), TestRunCtxStoreMemory (runCtx outlet),
TestDirectRunInjectsMemoryBlock (the [记忆] block reaches the request
with the pre-migration text shape and deterministic order).
Impact: assembly-side injection removed (the loop injects per request
— same text, no duplication); memory_write available on the kernel
path; recall defaults to all scopes (pre-migration semantics), with
AgentSpec.MemoryScope narrowing where hosts pass it.
Refs: Agent-Native#34, Agent-Squad#38, N3 plan WP5
2026-10-07 16:23:24 +00:00
weichen 80a7468523 Merge pull request 'Take the checkpoint closure: retention on rewind, HTTP tests, eligibility pin (N3 WP3)' (#94) from feature/n3-checkpoint-wp3 into main
ci / vet-and-test (push) Successful in 42s
Take the checkpoint closure: retention on rewind, HTTP tests, eligibility pin (N3 WP3)
2026-10-07 16:00:33 +00:00
weichen f862892544 Take the checkpoint closure: retention on rewind, HTTP tests, eligibility pin (N3 WP3)
ci / vet-and-test (pull_request) Successful in 42s
Why: the library now pins eligibility, guards symlinks, reports partial
restores, and bounds the log (Agent-Native#33); the product wires
retention after successful rewinds and needed the HTTP-layer and
eligibility evidence.
Evidence: go test -count=1 ./... green; TestRewindFilesHTTPHappy and
TestRewindFilesHTTPBusy409 (HTTP happy + conflict),
TestBackendCheckpointEligibilityIsWriteFileOnly (the product surface
checkpoints exactly {fs_write_file}).
Impact: checkpoints.jsonl is compacted to the latest 200 anchors after
each successful rewind.
Refs: Agent-Native#33, Agent-Squad#37, N3 plan WP3
2026-10-07 15:59:14 +00:00
weichen c392ed81ee Merge pull request 'Wire GenAI telemetry: settings surface, service name, and agent names (N3 WP4)' (#93) from feature/n3-otel-wp4 into main
ci / vet-and-test (push) Successful in 44s
Wire GenAI telemetry: settings surface, service name, and agent names (N3 WP4)
2026-10-07 15:42:21 +00:00
weichen 904742ca4b Wire GenAI telemetry: settings surface, service name, and agent names (N3 WP4)
ci / vet-and-test (pull_request) Successful in 40s
Why: spans now follow the OTel GenAI convention (Agent-Native#32); the
product needs the settings surface for the exporter, a meaningful
service.name, and per-profile agent names.
Evidence: go test -count=1 ./... green; TestTelemetryFlagsDefaults
(otlpEndpoint registered default empty = no export, serviceName
default agent-system-backend); nested pins carry the convention work.
Impact: nothing is exported unless telemetry.otlpEndpoint (or the env
var) is set; agent spans are named by profile id.
Refs: Agent-Native#32, Agent-Squad#36, N3 plan WP4
2026-10-07 15:41:01 +00:00
weichen dcd7c9a444 Merge pull request 'Give conversation runs the intent journal via JournalOnly sessions (N3 WP2)' (#92) from feature/n3-session-wp2 into main
ci / vet-and-test (push) Successful in 40s
Give conversation runs the intent journal via JournalOnly sessions (N3 WP2)
2026-10-07 15:27:47 +00:00
weichen 9f3fdb26e2 Give conversation runs the intent journal via JournalOnly sessions (N3 WP2)
ci / vet-and-test (pull_request) Successful in 42s
Why: the product conversation path had zero mid-turn durability
(llm_direct ran without Session — intents, recovery pairing, and
side-effect replay semantics never applied). Transcript-as-authority
would have frozen the assembled prefix, so the journal is wired in
JournalOnly mode: recovery runs against the transcript, the context
stays on the ctxasm pipeline, and recovered outcomes inject as a
system note.
Evidence: go test -count=1 ./... green;
TestDirectRunPairsAfterKillSeed (crash-window transcript gets the
crashInterrupted pair, run proceeds) and
TestDirectRunSeedsStoreHistoryOnce (history appears exactly once in
every request, never enters the journal); the forged-marker and
harness suites pass unchanged.
Impact: conversation runs now journal tool intents and recover them on
the next run; no transparent resume (sealed interrupted stays; the
user keeps chatting). Opening the transcript fails soft (falls back to
the old no-session behavior).
Refs: Agent-Native#31, Agent-Squad#35, N3 plan WP2
2026-10-07 15:26:27 +00:00
weichen 7a6a6075c2 Merge pull request 'Resume gracefully-interrupted subagents and take the RunState repair pins (N3 WP1)' (#91) from feature/n3-runstate-wp1 into main
ci / vet-and-test (push) Successful in 41s
Resume gracefully-interrupted subagents and take the RunState repair pins (N3 WP1)
2026-10-07 15:08:58 +00:00
weichen 1770fac51f Align the forged-marker guard test with the N6 semantics
ci / vet-and-test (pull_request) Successful in 50s
Why: Stop.Replacement no longer rewrites the in-memory assistant; the
correction reaches the model as a user note. The guard's protective
intent is unchanged (forged body never replays as assistant speech).
Evidence: go test -count=1 ./... green.
Impact: test only.
Refs: Agent-Native#30, N3 plan WP1
2026-10-07 15:06:58 +00:00
weichen 97bc5093bd Resume gracefully-interrupted subagents and take the RunState repair pins (N3 WP1)
Why: InterruptRunning wrote done.json{status:interrupted} for every
child, and ResumeInterruptedChildren skipped anything with done.json —
graceful shutdowns never resumed (only SIGKILL-style deaths did). done
markers now split by status: terminal states block resume, interrupted
does not. Nested pins carry repairOrphanTail and the N6 fix.
Evidence: go test -count=1 ./... green; TestResumeAfterGracefulInterruptMarker
(resumed once and only once), TestResumeSkipsCompletedMarker (terminal
states still block).
Impact: after any restart, interrupted children with a stored request
re-spawn exactly once regardless of how the process stopped.
Refs: Agent-Native#30, Agent-Squad#34, N3 plan WP1
2026-10-07 15:05:57 +00:00
weichen 7980a8a219 Merge pull request 'Give subagents per-run transcripts, resume, persisted notices, and the Background request field (N2 WP5)' (#90) from feature/n2-subagent-wp5 into main
ci / vet-and-test (push) Successful in 45s
Give subagents per-run transcripts, resume, persisted notices (N2 WP5)
2026-10-07 14:25:04 +00:00
weichen 358cfe017c Give subagents per-run transcripts, resume, persisted notices, and the Background request field (N2 WP5)
ci / vet-and-test (pull_request) Successful in 54s
Why: child runs had no independent transcript, no resume path (sealed
interrupted only), in-memory completion notices lost on restart, and
the subagent Background declaration had no consumer.
Evidence: go test -count=1 ./... green; TestSubagentTranscriptFilesWritten
(request/transcript/done files), TestResumeInterruptedChildrenRespawns
(re-spawn with original task + transcript-path hint, resumed.json
idempotent), TestNoticesPersistAndRedeliverAfterRestart (persist →
reload → deliver → delete), migration v17 pending_notices with up/down
on both dialects.
Impact: per-run transcript dirs under the workspace root (auxiliary,
write failures never abort spawns); interrupted children with a stored
request re-spawn as background runs at startup (pre-WP5 runs stay
sealed); notices survive restart; delegate factory honors
Request.Background with a sync fallback when the host lacks background
support.
Refs: Agent-Native#29, Agent-Squad#33, N2 plan WP5
2026-10-07 14:23:39 +00:00
weichen b44f78b09a Merge pull request 'Validate plugin and bundled skills against the open spec (N2 WP4)' (#89) from feature/n2-skills-wp4 into main
ci / vet-and-test (push) Successful in 51s
Validate plugin and bundled skills against the open spec (N2 WP4)
2026-10-07 13:58:47 +00:00
weichen df6745e820 Point the nested native and squad pins at the skills spec validation
ci / vet-and-test (pull_request) Successful in 41s
Why: internal/skills/validate.go calls ValidateSkillDocument, which
only exists on agent-native main after #28; the pin must move for CI
to build the new code.
Evidence: nested pins at 7425b32b / 9d3faefb (merged mains).
Impact: none beyond the pins.
Refs: Agent-Native#28, Agent-Squad#32
2026-10-07 13:57:52 +00:00
weichen 48c5202de2 Validate plugin and bundled skills against the open spec (N2 WP4)
Why: plugin skills were written to the skill root unvalidated and the
bundled recipes carried no name — both now go through the library's
spec validator (name required + kebab-case + equals dir, description
required, known-keys whitelist).
Evidence: go test -count=1 ./... green; TestV2InvalidSkillRejected pins
the fail-closed path with plugin context + field location; Seed
validates each SKILL.md before write (recipes dogfood-compliant);
nested pins carry agent-native#28 (the validator).
Impact: invalid plugin skills now abort the plugin load with a located
error instead of landing silently; the architecture baseline gains
backend/plugins→backend/skills.
Refs: Agent-Native#28, Agent-Squad#32, N2 plan WP4
2026-10-07 13:57:17 +00:00
weichen 1291b378c0 Merge pull request 'Point the nested native and squad pins at the MCP 2026-07-28 work' (#88) from feature/n2-wp3-cascade into main
ci / vet-and-test (push) Failing after 45s
Point the nested native and squad pins at the MCP 2026-07-28 work
2026-10-07 13:43:33 +00:00
weichen 7365b4c471 Point the nested native and squad pins at the MCP 2026-07-28 work
ci / vet-and-test (pull_request) Successful in 46s
Why: native 77e7f978 (Agent-Native#27, N2 WP3) and the squad cascade
carry the MCP 2026-07-28 alignment; the nested pins follow for the Dev
combination check.
Evidence: go build + go test -count=1 ./... green on this combination.
Impact: none beyond the pins.
Refs: Agent-Native#27, Agent-Squad#31
2026-10-07 13:42:14 +00:00
weichen 5dfe70d68d Merge pull request 'Point the nested native and squad pins at the Anthropic client work' (#87) from feature/n2-wp2-cascade into main
ci / vet-and-test (push) Successful in 54s
Point the nested native and squad pins at the Anthropic client work
2026-10-07 13:26:33 +00:00
weichen 7aa3bc447d Point the nested native and squad pins at the Anthropic client work
ci / vet-and-test (pull_request) Successful in 44s
Why: native d6053baf (Agent-Native#26, N2 WP2) and squad 41ea2766
(Agent-Squad#30) carry the native Anthropic client; the nested pins
follow for the Dev combination check.
Evidence: go build + go test -count=1 ./... green on this combination.
Impact: none beyond the pins.
Refs: Agent-Native#26, Agent-Squad#30
2026-10-07 13:24:45 +00:00
weichen 8858ca9b8a Merge pull request 'Align context trimming to tool groups and take the N2 WP1 pins' (#86) from feature/n2-context-wp1 into main
ci / vet-and-test (push) Successful in 42s
Align context trimming to tool groups and take the N2 WP1 pins
2026-10-07 13:13:09 +00:00
weichen 7acbefd60d Align context trimming to tool groups and take the N2 WP1 pins
ci / vet-and-test (pull_request) Successful in 44s
Why: trimOldest dropped single messages without tool-pair awareness —
after leading users were evicted it could orphan tool results or leave
dangling calls (acceptance clause 2's product-side gap).
Evidence: go test -count=1 ./... green; TestTrimOldestKeepsToolPairs
(pairing holds across repeated trims), TestTrimOldestDropsLeadingOrphanToolRun,
TestTrimOldestStopsWhenGroupCrossesCurrentTurn, TestPrefixHashStableForIdenticalInput;
native pins carry the spill and cache-contract work (Agent-Native#25).
Impact: trimming now evicts whole tool groups; the window may be
slightly exceeded when a group would cross the current turn — pairing
is never broken. Report.Truncations still records the count.
Refs: Agent-Native#25, Agent-Squad#29, N2 plan WP1
2026-10-07 13:11:47 +00:00
weichen 68addfda05 Merge pull request 'Point the nested squad and native pins at the CWD-clean fix' (#85) from feature/native-cascade-follow into main
ci / vet-and-test (push) Successful in 42s
Point the nested squad and native pins at the CWD-clean fix
2026-10-07 11:52:29 +00:00
weichen fcf2c73589 Point the nested squad and native pins at the CWD-clean fix
ci / vet-and-test (pull_request) Successful in 43s
Why: native ead690be keeps workspace-less requests out of the process
CWD (the Dev CI build step requires a clean tree after make test);
squad 029edc08 carries it.
Evidence: go build + go test ./... green on this combination.
Impact: none beyond the pins.
Refs: Agent-Native#24, Agent-Squad#28, Agent-System-Dev#242
2026-10-07 11:51:11 +00:00
weichen 33e261b224 Merge pull request 'Point the nested squad and native pins at the I2 cascade' (#84) from feature/agent-stack-i2-cascade into main
ci / vet-and-test (push) Successful in 46s
Point the nested squad and native pins at the I2 cascade
2026-10-07 11:34:20 +00:00
weichen fb90e6239a Point the nested squad and native pins at the I2 cascade
ci / vet-and-test (pull_request) Successful in 44s
Why: squad main (551e11ec) and native main (3a1ad5a) now carry the
nested agent-intent 343b1e6 pins the Dev combination check requires;
the backend nested copies follow.
Evidence: go build ./... green on this combination; trees identical
to the merged library mains.
Impact: none beyond the pins.
Refs: Agent-Squad#27, Agent-Native#23, I2 increment 3 cascade
2026-10-07 11:33:14 +00:00
weichen 9d2937115c Merge pull request 'Wire the intent slow lane behind settings flags (I2 WP3)' (#83) from feature/agent-stack-i2 into main
ci / vet-and-test (push) Successful in 40s
Wire the intent slow lane behind settings flags (I2 WP3)
2026-10-07 11:23:57 +00:00
weichen 3f5c0f60e4 Point the nested agent-intent pin at the I2 port work
ci / vet-and-test (pull_request) Successful in 43s
Why: the slow-lane wiring references SemanticScorer/CommandGenerator,
which only exist on agent-intent main after #9/#10; the pin must move
for CI to build the new packages.
Evidence: deps/agent-intent now at 343b1e6 (merged main).
Impact: none by itself.
Refs: Agent-System-Backend#83, I2 increment 3
2026-10-07 11:22:58 +00:00
weichen 45b011af9f Wire the intent slow lane behind settings flags (I2 WP3)
ci / vet-and-test (pull_request) Failing after 34s
Why: I2 increments 1-2 shipped the agent-intent ports (SemanticScorer,
CommandGenerator); the backend still had no implementation, so the slow
lane could not be enabled in the product.
Evidence: go test ./... green; lint-boundaries ok; the two new
runtime.intentSemantic / runtime.intentGenerator flags are registered
with default false and the gate defaults to the I1 recognizer when both
are off (TestIntentSlowLaneFlagsDefaultOff, TestGate*).
Impact: no behavior change by default; enabling runtime.intentSemantic
routes ambiguous rule matches through embedding cosine scoring, and
runtime.intentGenerator lets the lightweight model propose commands on
fallback with library-side fail-closed validation. Nested agent-intent
pin moves to 343b1e6 (I2 increments 1-2).
Refs: I2 increment 3
2026-10-07 11:20:41 +00:00
weichen 3261135126 Merge pull request 'Point the nested agent-squad pin at the SQLite convergence' (#82) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 10:33:02 +00:00
weichen 3de400b740 Point the nested agent-squad pin at the SQLite convergence
ci / vet-and-test (pull_request) Successful in 40s
Why: Agent-Squad PR #26 landed the SQLite Store, single-DB mounting
and the Executor-port fix; backend must carry the same head or Dev
check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Nested squad pin only.
2026-10-07 10:32:16 +00:00
weichen 5883d10889 Merge pull request 'Point the nested agent-squad pin at the Store and termination work' (#81) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 41s
2026-10-07 10:03:14 +00:00
weichen 58d9a17b8a Point the nested agent-squad pin at the Store and termination work
ci / vet-and-test (pull_request) Successful in 42s
Why: Agent-Squad PR #25 landed the Store port, tiered checkpoints
and the termination registry; backend must carry the same head or
Dev check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Nested squad pin only.
2026-10-07 10:02:28 +00:00
weichen 7ea21c3ca2 Merge pull request 'Point the nested agent-squad pin at the attempt-id recovery' (#80) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 42s
2026-10-07 09:20:29 +00:00
weichen 15fb79e30e Point the nested agent-squad pin at the attempt-id recovery
ci / vet-and-test (pull_request) Successful in 41s
Why: Agent-Squad PR #24 landed attempt ids and product-checked
interrupted recovery; backend must carry the same head or Dev
check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Nested squad pin only.
2026-10-07 09:19:43 +00:00
weichen a59980898f Merge pull request 'Point the nested agent-squad pin at the superstep engine' (#79) from feature/agent-stack-q1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 09:09:54 +00:00
weichen 617a4566be Point the nested agent-squad pin at the superstep engine
ci / vet-and-test (pull_request) Successful in 41s
Why: Agent-Squad PR #23 landed the superstep engine; backend must
carry the same squad head or Dev check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Nested squad pin only.
2026-10-07 09:09:08 +00:00
weichen 6f27567acd Merge pull request 'Point the nested agent-squad pin at the native-archive cascade head' (#78) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 41s
2026-10-07 08:34:58 +00:00
weichen 64e80c9bb6 Merge origin/main into the I1 backend branch
ci / vet-and-test (pull_request) Successful in 42s
2026-10-07 08:31:44 +00:00
weichen 40ebca7f07 Point the nested agent-squad pin at the native-archive cascade
ci / vet-and-test (pull_request) Successful in 41s
Why: Squad PR #22 landed the nested native bump; backend must
carry the same squad head or Dev check-pins fails.

Evidence: squad main head is the #22 merge.

Impact: Nested squad pin only.
2026-10-07 08:28:45 +00:00
weichen 12f50ef2dc Merge pull request 'Point the nested pins at the eval-v2 combination' (#77) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 42s
2026-10-07 08:26:13 +00:00
weichen 0a26fb598b Point the nested pins at the eval-v2 combination
ci / vet-and-test (pull_request) Successful in 41s
Why: Agent-Intent PR #8 and its cascades (Squad #21, Native #22)
landed; backend's nested pins must carry those heads or Dev
check-pins fails.

Evidence: backend suite 30 packages ok at this combination.

Impact: Three nested gitlinks.
2026-10-07 08:25:27 +00:00
weichen 07250a221a Merge pull request 'Project the command types through the intent gate surface' (#76) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 07:59:08 +00:00
weichen 517ae236b3 Project the command types through the intent gate surface
ci / vet-and-test (pull_request) Successful in 42s
Why: The boundary linter allows agent-intent imports only in the
adapter layer; route and runmgr consume aliases from intentgate
instead of importing the ecosystem library directly.

Evidence: lint-boundaries passes below; backend suite green.

Impact: intentgate re-exports Command/ActionRef and the kind
constants; route drops the agent-intent import.
2026-10-07 07:58:05 +00:00
weichen a70b5a2f6a Merge pull request 'Point the nested pins at the I1 router combination' (#75) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 42s
2026-10-07 07:51:22 +00:00
weichen 6c7563bd15 Point the nested pins at the I1 router combination
ci / vet-and-test (pull_request) Successful in 41s
Why: Squad PR #20 and Agent-Native PR #21 completed the intent
cascade; backend's nested pins must carry those heads or Dev
check-pins fails.

Evidence: squad and native heads are direct merges on their mains;
backend suite green before the pin bump.

Impact: Two nested gitlinks.
2026-10-07 07:50:04 +00:00
weichen 4da3cfc787 Merge pull request 'Unify intent routing behind the route package and enable NFKC (I1 WP3/WP4)' (#74) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 42s
2026-10-07 07:46:07 +00:00
weichen ff43c7c9aa Unify intent routing behind the route package and enable NFKC
ci / vet-and-test (pull_request) Successful in 41s
Why: I1 WP3 - chat routing and the HITL free-text pivot now share one
router consuming the library's command list, and both intent hosts
fold compatibility-equivalent input.

Evidence: go vet clean; go test ./... 30 packages ok, including the
existing workflow-route and server e2e suites unchanged (behavior
parity) and the new route package tests.

Impact: new internal/route package (unique router with explicit
priority rules); intentgate carries Commands and folds NFKC;
server.go and runmgr.go route through it; nested pins at intent
2554b50 and squad 6b7a991. Blueprint actions resolve to a squad
decision at the router level; per-run squad entry stays a recorded
follow-up (no silent misrouting).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP3/WP4
2026-10-07 07:44:31 +00:00
weichen 303983bd3e Merge pull request 'Point the nested agent-squad pin at the WP5 cascade head' (#73) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 06:40:18 +00:00
weichen 6526a0b0e3 Point the nested agent-squad pin at the WP5 cascade head
ci / vet-and-test (pull_request) Successful in 41s
Why: Agent-Squad PR #18 landed the nested agent-native WP5 bump;
backend must carry the same squad head or Dev check-pins fails.

Evidence: squad main head is the #18 merge; backend suite runs next.

Impact: Nested squad pin only.
2026-10-07 06:38:36 +00:00
weichen 395a9907d7 Merge pull request 'Consume the sunk gate mechanics and pin the WP5 library (N1 WP5/WP6)' (#72) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 40s
2026-10-07 06:36:10 +00:00
weichen 4e3c0b60c1 Merge origin/main into the N1 backend branch
ci / vet-and-test (pull_request) Successful in 41s
2026-10-07 06:35:38 +00:00
weichen 5f9e77a500 Consume the sunk gate mechanics and pin the WP5 library
ci / vet-and-test (pull_request) Successful in 40s
Why: N1 WP5 host side - directRun.stop and the checklist reminders
now call the library gates package behind product ports, and the
nested native pin moves to the merged WP5 head.

Evidence: go vet clean; go test ./... 29 packages ok, including the
characterization suite unmodified and the new wp5_ab_test.go
(verify-cap continuation counts and gate-reason text parity).

Impact: llm.go drops the local regex family and deliverableGate;
verify.go drops checkDeliverables/parseVerdict/stripFence and the
local caps; workflow.go switches to gates.Verdict; reminders run
through gates.ProgressReminder. Behavior is pinned identical by the
existing suite plus the A/B tests. Squad reminders stay out of this
increment (adapter lacks per-member tool-turn signals - recorded as
WP6b).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP5/WP6
2026-10-07 06:31:58 +00:00
weichen f0b0db9aaa Merge pull request 'Validate plugin agent declarations and wire the squad ask port (N1 WP1/WP4)' (#71) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 39s
2026-10-07 05:15:36 +00:00
weichen dc6d9985d8 Validate plugin agent declarations and wire the squad ask port
ci / vet-and-test (pull_request) Successful in 41s
Why: N1 increment three host side - plugin agent assets must reject
hooks/mcp/permissionMode declarations (WP1 acceptance), and squad
members need the kernel ask port wired to the HITL card (WP4).

Evidence: go vet clean; go test ./... 29 packages ok, including new
agent-asset tests (banned keys located to the field, unknown keys,
missing id) and the V2 asset suite. Nested pins at native c5b9dca
(PR #18) and squad fc9620e (PR #17).

Impact: plugins agents/ assets now parse via parseAgentAsset
(strict JSON with banned-key rejection); PermissionAskOf exported
and takes any host with the optional method; app.go wires
cfg.PermissionAsk for the squad path. Legacy behavior unchanged when
nothing registers the port.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP1/WP4
2026-10-07 05:14:06 +00:00
weichen d34c2e8568 Merge pull request 'Point the nested agent-squad pin at the cascaded N1 combination' (#70) from feature/agent-stack-n1-pins into main
ci / vet-and-test (push) Successful in 39s
2026-10-07 04:44:36 +00:00
weichen 9239353239 Point the nested agent-squad pin at the cascaded N1 combination
ci / vet-and-test (pull_request) Successful in 39s
Why: Agent-Squad PR #16 landed the nested agent-native bump; backend
must carry the same squad head or Dev check-pins fails.

Evidence: squad main head is the #16 merge; backend test suite runs
next.

Impact: Nested squad pin only.
2026-10-07 04:43:20 +00:00
weichen fe0c5a4f1b Merge pull request 'Wire the permission ask port to HITL and adopt the Policy object (N1 WP2/WP3)' (#69) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 38s
2026-10-07 04:38:53 +00:00
weichen 4075cc6501 Point backend at the merged N1 ask-loop and Policy increment
ci / vet-and-test (pull_request) Successful in 38s
Why: Library PR #17 landed on agent-native main as c2befaf; backend
compiles with the merged combination, not the pre-merge branch head.

Evidence: go vet and full go test run next; native main ancestry of
9758a8d verified before the pin bump.

Impact: Nested agent-native pin only.
2026-10-07 04:37:43 +00:00
weichen 16dbe70014 Merge origin/main into the N1 backend branch 2026-10-07 04:37:33 +00:00
weichen 17f7e2b168 Wire the permission ask port to HITL and adopt the Policy object
ci / vet-and-test (pull_request) Successful in 40s
Why: N1 WP2/WP3 host side - the kernel ask loop must reach the HITL
confirm card, and tool gating should run through the typed Policy with
the session-allow store port instead of a hand-built Request.

Evidence: go vet clean; go test ./... all packages ok, including new
TestConvAllowStoreKeysByName, TestPermissionAskRoutesToHITL, and
permissionAskOf tests. Nested agent-native pin at 9758a8d (PR #17).

Impact: runmgr.ExecuteTool decides via agentpolicy.Policy with
convAllowStore (keying unchanged: conversation x tool name);
runCtx.PermissionAsk routes kernel asks to the shared toolConfirmCard
HITL; direct and subagent loops pass PermissionAsk into the kernel
config (nil for hosts without the method, preserving legacy ask=reject).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP2/WP3; Agent-Native PR #17
2026-10-07 04:04:10 +00:00
weichen 838f7a5e14 Merge pull request 'Skip wide sparse columns without dropping later xlsx sheets' (#68) from fix/ext-5-wide-cols into main
ci / vet-and-test (push) Successful in 41s
2026-10-06 15:39:42 +00:00
weichen e7fb276ee9 Skip wide sparse columns without dropping later xlsx sheets
ci / vet-and-test (pull_request) Successful in 41s
Why: review of EXT-5 found a column-cap cell (common styled XFD cells) wrongly reported the 200k-cell cap and aborted every remaining worksheet; malformed sheet XML was also dropped silently, contradicting the explicit-failure contract; the legacy-office parser id was missing from the registry.

Evidence: local GOWORK=off go test ./... 29 packages pass with new regressions (wide column keeps later sheets, bad sheet XML warns, registry maps doc/xls/ppt); go vet clean; recorded as SELF on the Dev task card.

Impact: xlsxSheetBlocks returns wide/badXML signals; wide cells skip with a per-sheet warning, cell-cap truncation unchanged; builtin:legacy-office added to ParsersDescription.

Refs: review of backend 727f370; documents proposal 2026-10-06-ext-5-host-parse-document sect.2.
2026-10-06 15:30:50 +00:00
weichen 736f0a0839 Merge pull request 'Expose builtin document parsing through the host handle' (#67) from feature/ext-5-host-parse into main
ci / vet-and-test (push) Successful in 43s
2026-10-06 15:14:21 +00:00
weichen 727f3702d0 Expose builtin document parsing through the host handle
ci / vet-and-test (pull_request) Successful in 46s
Why: downstream parsers are unreachable via extension.Host and the base lacked xlsx/pptx parsers; MS1 needs Excel and PPT ingest (EXT-5 request, documents proposal 2026-10-06-ext-5-host-parse-document).

Evidence: local GOWORK=off go test ./... all 29 packages pass incl. new ooxml_test (hand-built parts), ooxml_python_test (openpyxl/python-pptx round-trip), inproc owner-scope and limit tests, xlsx upload e2e; go vet clean; recorded as SELF on the Dev task card, PR CI to confirm.

Impact: Host gains ParseDocument (pure compute; errors only on ctx cancel or >100MiB input); builtin:xlsx (sheet order via workbook.xml rels, rich-text shared strings, formula cache, 1900/1904 dates, sparse cells) and builtin:pptx (sldIdLst order, titles, group shapes, AlternateContent Choice, tables, notes) added with 200k-cell/64MiB-part caps; legacy doc/xls/ppt get an actionable hint; ParsersDescription gains xlsx/pptx and the missing text ext; surface snapshot now records exported interface methods; docx part reads share the 64MiB cap.

Refs: documents 2026-10-06-ext-5-host-parse-document.md; EXT-1 host.go precedent.
2026-10-06 14:59:38 +00:00
weichen f9212d01b7 Merge pull request 'Merge EXT-1 inproc plugin command handler into main' (#66) from feature/ext-1-inproc-handler into main
ci / vet-and-test (push) Successful in 41s
2026-10-06 01:25:37 +00:00
167 changed files with 13746 additions and 1809 deletions
+11 -2
View File
@@ -44,13 +44,16 @@ func main() {
instance := fs.String("instance", "", "本进程标识(空 = 主机名:监听地址;启动对账只收口这个实例的 run)")
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 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
fs.Parse(os.Args[1:])
cliSet := visited(fs)
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,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
@@ -60,6 +63,7 @@ func main() {
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, ExecBackend: resolved.ExecBackend,
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
@@ -76,6 +80,8 @@ func visited(fs *flag.FlagSet) map[string]bool {
name = "insecureListen"
case "allowed-hosts":
name = "allowedHosts"
case "disable-exec-tools":
name = "disableExecTools"
}
set[name] = true
})
@@ -100,11 +106,14 @@ func runConfig(args []string) int {
instance := fs.String("instance", "", "")
insecure := fs.Bool("insecure-listen", false, "")
allowedHosts := fs.String("allowed-hosts", "", "")
noExec := fs.Bool("disable-exec-tools", false, "")
execBackend := fs.String("exec-backend", "", "")
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,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend,
}, visited(fs))
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
+175
View File
@@ -0,0 +1,175 @@
package eval
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// ab.go — 任务级 A/B 双跑 harness(Q3 WP5):同一批任务分别经两个入口
// (direct 对话循环 / squad 编译图)执行,逐任务记录差异(不要求字节级
// 相同),汇总为两份 EntryScore 交 DecideSwitch 裁决默认入口切换。
// 本包保持纯机制(不依赖 runtime/squad);两侧 RunFunc 由调用方装配。
// ABTask 是对照批中的一个任务。
type ABTask struct {
ID string
Goal string
// Themes 期望产出要点(短词,非整句照抄):打分用 MissingThemes,
// 差异记录用要点覆盖对比。
Themes []string
}
// ABOutcome 是一个入口对一个任务的执行结果(调用方 RunFunc 填写)。
type ABOutcome struct {
// Completed 任务是否正常收口(失败/取消 = false)。
Completed bool
// Summary 交付摘要(要点覆盖打分的对象;调用方可拼接工件正文)。
Summary string
// FirstToken 首字延迟(入口收到任务到首个可见输出)。
FirstToken time.Duration
// Err 失败原因(Completed=false 时填写)。
Err string
}
// RunFunc 是一个入口的执行侧(direct 或 squad 编译图)。
type RunFunc func(ctx context.Context, task ABTask) (ABOutcome, error)
// ABTaskRecord 是一个任务的双跑对照记录(差异记录单位)。
type ABTaskRecord struct {
Task ABTask
Direct ABOutcome
Squad ABOutcome
// Diffs 差异注记(状态 / 要点覆盖 / 首字延迟),空 = 两侧一致。
Diffs []string
// DirectPoints / SquadPoints 单任务得分(满分 100:完成 + 要点全覆盖;
// 未完成 0 分;完成但缺要点按缺失比例扣)。
DirectPoints int
SquadPoints int
}
// ABReport 是一批双跑的对照报告(切换裁决的证据形态)。
type ABReport struct {
Records []ABTaskRecord
Direct EntryScore
Squad EntryScore
Decision SwitchDecision
// Text 是可附在切换 PR / 任务卡上的短报告(Report(Decision) + 差异清单)。
Text string
}
// RunAB 双跑同一批任务并汇总对照报告。任一侧 RunFunc 返回 error 视为该侧
// 该任务失败(记录进 outcome.Err),不中断整批(对照要的是全批差异面)。
func RunAB(ctx context.Context, tasks []ABTask, direct, squad RunFunc) ABReport {
rep := ABReport{}
if len(tasks) == 0 {
rep.Decision = DecideSwitch(nil, nil)
rep.Text = Report(rep.Decision)
return rep
}
var directFT, squadFT []time.Duration
var directSum, squadSum int
for _, task := range tasks {
rec := ABTaskRecord{Task: task}
rec.Direct = runOne(ctx, task, direct)
rec.Squad = runOne(ctx, task, squad)
rec.DirectPoints = scoreOutcome(rec.Direct, task.Themes)
rec.SquadPoints = scoreOutcome(rec.Squad, task.Themes)
rec.Diffs = diffRecord(rec)
rep.Records = append(rep.Records, rec)
directSum += rec.DirectPoints
squadSum += rec.SquadPoints
directFT = append(directFT, rec.Direct.FirstToken)
squadFT = append(squadFT, rec.Squad.FirstToken)
}
rep.Direct = EntryScore{Name: "direct", Score: directSum / len(tasks), FirstTokenP50: p50(directFT)}
rep.Squad = EntryScore{Name: "squad", Score: squadSum / len(tasks), FirstTokenP50: p50(squadFT)}
rep.Decision = DecideSwitch(&rep.Direct, &rep.Squad)
rep.Text = renderReport(rep)
return rep
}
func runOne(ctx context.Context, task ABTask, fn RunFunc) ABOutcome {
if fn == nil {
return ABOutcome{Err: "入口未装配"}
}
out, err := fn(ctx, task)
if err != nil {
out.Completed = false
if out.Err == "" {
out.Err = err.Error()
}
}
return out
}
// scoreOutcome 单任务打分:未完成 0;完成 = 100 - 缺失要点比例 × 100。
func scoreOutcome(out ABOutcome, themes []string) int {
if !out.Completed {
return 0
}
if len(themes) == 0 {
return 100
}
missing := MissingThemes(out.Summary, themes)
return 100 - len(missing)*100/len(themes)
}
// diffRecord 逐任务差异注记(状态 / 要点 / 首字延迟;不要求字节级相同)。
func diffRecord(rec ABTaskRecord) []string {
var diffs []string
if rec.Direct.Completed != rec.Squad.Completed {
diffs = append(diffs, fmt.Sprintf("状态:direct=%v squad=%v(%s / %s)",
rec.Direct.Completed, rec.Squad.Completed, rec.Direct.Err, rec.Squad.Err))
}
if len(rec.Task.Themes) > 0 {
dm := MissingThemes(rec.Direct.Summary, rec.Task.Themes)
sm := MissingThemes(rec.Squad.Summary, rec.Task.Themes)
if strings.Join(dm, ",") != strings.Join(sm, ",") {
diffs = append(diffs, fmt.Sprintf("要点缺失:direct=%v squad=%v", dm, sm))
}
}
gap := rec.Squad.FirstToken - rec.Direct.FirstToken
if gap < 0 {
gap = -gap
}
if gap > time.Second {
diffs = append(diffs, fmt.Sprintf("首字延迟差:%s(direct=%s squad=%s)",
gap, rec.Direct.FirstToken, rec.Squad.FirstToken))
}
if rec.Direct.Completed && rec.Squad.Completed &&
strings.TrimSpace(rec.Direct.Summary) != strings.TrimSpace(rec.Squad.Summary) {
diffs = append(diffs, "摘要文本不同(记录在案,不要求字节级相同)")
}
return diffs
}
func p50(ds []time.Duration) time.Duration {
if len(ds) == 0 {
return 0
}
sorted := append([]time.Duration(nil), ds...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
return sorted[len(sorted)/2]
}
// renderReport 报告全文:裁决 + 分数 + 逐任务差异清单。
func renderReport(rep ABReport) string {
var b strings.Builder
b.WriteString(Report(rep.Decision))
fmt.Fprintf(&b, "\n分数:direct=%d squad=%d;首字P50:direct=%s squad=%s\n",
rep.Direct.Score, rep.Squad.Score, rep.Direct.FirstTokenP50, rep.Squad.FirstTokenP50)
for _, rec := range rep.Records {
fmt.Fprintf(&b, "- %s:direct %d 分 / squad %d 分", rec.Task.ID, rec.DirectPoints, rec.SquadPoints)
if len(rec.Diffs) == 0 {
b.WriteString("(无差异注记)")
}
b.WriteString("\n")
for _, d := range rec.Diffs {
fmt.Fprintf(&b, " - %s\n", d)
}
}
return strings.TrimRight(b.String(), "\n")
}
+124
View File
@@ -0,0 +1,124 @@
package eval
import (
"context"
"strings"
"testing"
"time"
)
// ab_test.go — Q3 WP5:A/B 双跑 harness 机制面(差异记录 / 打分 / P50 /
// DecideSwitch 联动)。真实入口的集成对照在 internal/wfcompile 的
// demo.brief 双跑测试。
func stubRun(completed bool, summary string, ft time.Duration, errText string) RunFunc {
return func(context.Context, ABTask) (ABOutcome, error) {
return ABOutcome{Completed: completed, Summary: summary, FirstToken: ft, Err: errText}, nil
}
}
func TestRunABRecordsDiffsAndScores(t *testing.T) {
tasks := []ABTask{
{ID: "t1", Goal: "写简报", Themes: []string{"太空电梯", "核对标记"}},
{ID: "t2", Goal: "查资料", Themes: []string{"三来源"}},
}
direct := func(_ context.Context, task ABTask) (ABOutcome, error) {
switch task.ID {
case "t1":
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报,核对标记:P4", FirstToken: 100 * time.Millisecond}, nil
default:
return ABOutcome{Completed: true, Summary: "对照完成,三来源齐", FirstToken: 200 * time.Millisecond}, nil
}
}
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
switch task.ID {
case "t1":
// 完成但缺一个要点 → 扣分 + 差异注记。
return ABOutcome{Completed: true, Summary: "关于太空电梯的简报", FirstToken: 100 * time.Millisecond}, nil
default:
return ABOutcome{Completed: false, Err: "编译图步骤失败", FirstToken: 0}, nil
}
}
rep := RunAB(context.Background(), tasks, direct, squad)
if len(rep.Records) != 2 {
t.Fatalf("records = %d", len(rep.Records))
}
r1 := rep.Records[0]
if r1.DirectPoints != 100 || r1.SquadPoints != 50 {
t.Fatalf("t1 points = %d/%d", r1.DirectPoints, r1.SquadPoints)
}
if !strings.Contains(strings.Join(r1.Diffs, ";"), "要点缺失") {
t.Fatalf("t1 diffs = %v", r1.Diffs)
}
r2 := rep.Records[1]
if r2.SquadPoints != 0 || !strings.Contains(strings.Join(r2.Diffs, ";"), "状态") {
t.Fatalf("t2 = %d %v", r2.SquadPoints, r2.Diffs)
}
// 分数汇总:direct 100,squad (50+0)/2=25 → 不达标,保持 direct。
if rep.Direct.Score != 100 || rep.Squad.Score != 25 {
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
}
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "低于") {
t.Fatalf("decision = %+v", rep.Decision)
}
if !strings.Contains(rep.Text, "保持 direct") {
t.Fatalf("report = %s", rep.Text)
}
}
func TestRunABSwitchMetWhenEqual(t *testing.T) {
tasks := []ABTask{{ID: "t1", Goal: "g", Themes: []string{"要点"}}}
fn := stubRun(true, "覆盖要点的产出", 50*time.Millisecond, "")
rep := RunAB(context.Background(), tasks, fn, fn)
if rep.Direct.Score != 100 || rep.Squad.Score != 100 {
t.Fatalf("scores = %d/%d", rep.Direct.Score, rep.Squad.Score)
}
if !rep.Decision.Met {
t.Fatalf("同分同延迟应达标:%+v", rep.Decision)
}
if len(rep.Records[0].Diffs) != 0 {
t.Fatalf("同产出应无差异注记:%v", rep.Records[0].Diffs)
}
if rep.Direct.FirstTokenP50 != 50*time.Millisecond {
t.Fatalf("p50 = %s", rep.Direct.FirstTokenP50)
}
}
func TestRunABLatencyGate(t *testing.T) {
tasks := []ABTask{{ID: "t1", Goal: "g"}}
direct := stubRun(true, "ok", 100*time.Millisecond, "")
squad := stubRun(true, "ok", 4*time.Second, "") // 额外首字 > 3s
rep := RunAB(context.Background(), tasks, direct, squad)
if rep.Decision.Met || !strings.Contains(rep.Decision.Reason, "超过 3 秒") {
t.Fatalf("decision = %+v", rep.Decision)
}
if !strings.Contains(strings.Join(rep.Records[0].Diffs, ";"), "首字延迟差") {
t.Fatalf("diffs = %v", rep.Records[0].Diffs)
}
}
// RunFunc 返回 error:记为该侧失败,不中断整批。
func TestRunABToleratesRunFuncError(t *testing.T) {
tasks := []ABTask{{ID: "t1"}, {ID: "t2"}}
direct := stubRun(true, "ok", 0, "")
squad := func(_ context.Context, task ABTask) (ABOutcome, error) {
if task.ID == "t1" {
return ABOutcome{}, context.DeadlineExceeded
}
return ABOutcome{Completed: true, Summary: "ok"}, nil
}
rep := RunAB(context.Background(), tasks, direct, squad)
if rep.Records[0].SquadPoints != 0 || rep.Records[0].Squad.Err == "" {
t.Fatalf("t1 = %+v", rep.Records[0].Squad)
}
if rep.Records[1].SquadPoints != 100 {
t.Fatalf("t2 应不受 t1 失败影响:%+v", rep.Records[1])
}
}
func TestRunABEmptyBatch(t *testing.T) {
rep := RunAB(context.Background(), nil, nil, nil)
if rep.Decision.Met {
t.Fatal("空批不得达标(无实测分数)")
}
}
+440
View File
@@ -0,0 +1,440 @@
//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"
)
// 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
}
// 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.FinalVsArtifact) > 0 {
fmt.Fprintf(&b, "\n终轮声称但工件不存在的章节:%s\n", strings.Join(tk.FinalVsArtifact, ";"))
}
fmt.Fprint(&b, "\n")
}
return b.String()
}
+314
View File
@@ -0,0 +1,314 @@
//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"`
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.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": ["生成会议纪要"]
}
]
+128 -16
View File
@@ -36,6 +36,7 @@ import (
"agent-system-backend/internal/config"
"agent-system-backend/internal/hostapi"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsem"
"agent-system-backend/internal/pgbus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
@@ -65,6 +66,19 @@ type Options struct {
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
InsecureListen bool
AllowedHosts []string
// DisableUploadIndexing EXT-9:true 时上传端点不自动写检索索引(发布决定
// 移交下游命令层);零值=false=现状。由 link 层自 extension.Options 透传。
DisableUploadIndexing bool
// DisableConvCardMirror EXT-12:true 时会话卡不镜像进项目公共流(不发布
// 即不持久,重放不含会话卡);零值=false=现状全量镜像。
DisableConvCardMirror bool
// DisableExecTools 启动级硬关闭执行原语(run_shell / run_python /
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
// 零值=false=现状。
DisableExecTools bool
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
// 硬关闭 DisableExecTools 优先级高于后端选择。
ExecBackend string
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -83,7 +97,6 @@ func Run(opt Options) error {
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
}
traceFn := telemetry.StartFunc(logger)
var st *store.Store
var err error
if opt.StorageDSN != "" {
@@ -112,7 +125,19 @@ func Run(opt Options) error {
}
// 执行原语(agent-native 交付):shell/python 在会话工作区执行 + 产物登记桥,
// runtime.exec=false 可整体禁用(fail-closed)。生成文档/表格/演示稿的方法
// 在提示词库 base.deliverables,不在系统工具里。
// 在提示词库 base.deliverables,不在系统工具里。启动参数 DisableExecTools
// 为不受用户设置影响的硬关闭(多用户部署的默认安全档)。
tools.SetExecDisabled(opt.DisableExecTools)
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 err := tools.RegisterExecTools(reg); err != nil {
return fmt.Errorf("exec tools: %w", err)
}
@@ -123,6 +148,10 @@ func Run(opt Options) error {
return fmt.Errorf("code orchestration tool: %w", err)
}
setSvc := settings.NewService(st)
effBoot := setSvc.Effective(store.OwnerDefault, "")
traceFn := telemetry.StartFunc(logger,
telemetry.WithServiceName(effBoot.GetString("telemetry.serviceName", "agent-system-backend")),
telemetry.WithEndpoint(effBoot.GetString("telemetry.otlpEndpoint", "")))
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
// 点路径键展开必须在读 tools.tavily.apiKeys 之前,否则启动仍注册旧池。
norm, err := setSvc.NormalizeStored()
@@ -147,18 +176,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 {
@@ -255,6 +288,9 @@ func Run(opt Options) error {
}
}
cfg.Trace = traceFn
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
if host, ok := rc.(interface {
OpenCheckpoints() (*checkpoint.Log, error)
CheckpointAnchor() string
@@ -294,6 +330,8 @@ func Run(opt Options) error {
if err := mgr.ReconcileInterrupted(); err != nil {
return fmt.Errorf("reconcile interrupted runs: %w", err)
}
// 后台子代理通知重启重投(N2 WP5):未投递结果装回队列并按空闲语义投递。
mgr.LoadPendingNotices()
mgr.SetSquadRunner(&runtime.SquadRunner{})
// 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。
@@ -327,14 +365,30 @@ func Run(opt Options) error {
// 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。
pipe := rag.NewPipeline(st, setSvc, nil, nil)
pipe.SetEmbedder(rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
embedder := rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
return setSvc.Effective(store.OwnerDefault, "").GetString("models.embedding.provider", "")
}})
}}
pipe.SetEmbedder(embedder)
pipe.SetReranker(rag.NewHTTPReranker(pipe.RerankRoute))
mgr.SetRetriever(pipe)
mgr.SetIndexer(pipe)
// 意图慢通道(I2):语义向量裁决 + 轻量模型命令生成。
// 两个开关都默认关——此时识别行为与 I1 完全一致(fail-closed 分层);
// 开启语义需已配置 embedding 端点,开启生成需可用的轻量/主模型档。
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentSemantic", false) {
gate.SetSemantic(intentsem.EmbeddingScorer{Provider: embedder}, 0)
}
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentGenerator", false) {
effNow := setSvc.Effective(store.OwnerDefault, "")
light := runtime.ResolveLightweightModel(core.AgentProfile{}, effNow)
client := &agentllm.Client{BaseURL: light.Base, APIKey: light.Key, HTTPClient: &http.Client{}}
gate.SetGenerator(intentsem.LightGenerator{Stream: client.Stream, Model: light.Model})
}
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
// 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。
mgr.ResumeInterruptedChildren()
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
@@ -345,7 +399,19 @@ func Run(opt Options) error {
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
caps := hostcaps{st: st, pipe: pipe, set: setSvc}
authzSlot := &projectAuthz{}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot}
// 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)
mgr.OnUsage = func(owner string, tokens int) {
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
@@ -369,11 +435,33 @@ func Run(opt Options) error {
hostSrv.Search = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return caps.searchDefault(ctx, owner, text)
}
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
own, err := caps.searchDefault(ctx, owner, text)
if err != nil {
return nil, err
}
shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool {
ok, aerr := authzSlot.Authorize(owner, projectID)
return aerr == nil && ok
})
if err != nil {
return nil, err
}
return append(own, shared...), nil
}
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
return caps.complete(ctx, owner, prompt)
}
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
srv.SetProjectAuthorizer(authzSlot.Authorize)
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
srv.DisableUploadIndexing = opt.DisableUploadIndexing
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
cardSvc.MirrorConvCards = !opt.DisableConvCardMirror
srv.Workflows = wfReg
mgr.SetWorkflows(wfReg)
srv.Cards = cardSvc
@@ -632,3 +720,27 @@ 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 {
src := a.bus.CommandTools()
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,
}
}
+34
View File
@@ -3,6 +3,7 @@ package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
@@ -47,6 +48,21 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
})
}
// 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
@@ -65,6 +81,24 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error {
return r.bus.RegisterPluginCommands([]json.RawMessage{spec})
}
// RegisterResourceAuthorizer 声明式扩展无宿主进程内代码,不能提供回调(fail-closed)。
func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorizer) error {
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
+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 应被拒绝")
}
}
+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)
}
}
+81
View File
@@ -0,0 +1,81 @@
package app
import (
"context"
"path/filepath"
"testing"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// newExt9Host EXT-9 测试装配:真实 store + rag 管线 + inprocHost(无 embedder,
// embed=false 走全文档路径)。
func newExt9Host(t *testing.T) (interface {
KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error
KnowledgeDelete(ctx context.Context, sourcePrefix string) error
}, *store.Store, *rag.Pipeline) {
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)
h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set}, pluginID: "rdm", owner: "alice"}
return h, st, pipe
}
func searchAll(t *testing.T, pipe *rag.Pipeline, owner, text string) []core.RetrievalHit {
t.Helper()
hits, err := pipe.Retrieve(context.Background(), core.ID(owner), core.RetrievalQuery{Text: text, TopK: 50})
if err != nil {
t.Fatalf("retrieve: %v", err)
}
return hits
}
// EXT-9 验收:publish 按 ref 幂等(重试不重复);delete 后检索即停且幂等;
// manual 来源不经 host 可删(前缀强制 plugin:<自身>)。
func TestKnowledgePublishIdempotentAndRevoke(t *testing.T) {
h, st, pipe := newExt9Host(t)
ctx := context.Background()
docs := []core.KnowledgeDoc{
{Ref: "batch-001", Chunk: "EXT9甲 独占令牌之一陈述"},
{Ref: "batch-001", Chunk: "EXT9乙 独占令牌之二陈述"},
}
if err := h.KnowledgePublish(ctx, docs, false); err != nil {
t.Fatalf("publish#1: %v", err)
}
if err := h.KnowledgePublish(ctx, docs, false); err != nil { // 重试幂等
t.Fatalf("publish#2: %v", err)
}
hits := searchAll(t, pipe, "alice", "独占令牌")
if len(hits) != 2 {
t.Fatalf("after double publish: hits=%d, want 2(幂等不重复;got %+v)", len(hits), hits)
}
if err := h.KnowledgeDelete(ctx, "plugin:rdm"); err != nil {
t.Fatalf("delete: %v", err)
}
if hits = searchAll(t, pipe, "alice", "独占令牌"); len(hits) != 0 {
t.Fatalf("after delete: hits=%d, want 0(撤销即停)", len(hits))
}
if err := h.KnowledgeDelete(ctx, "plugin:rdm"); err != nil { // 幂等
t.Fatalf("delete#2: %v", err)
}
// manual 来源:host 前缀强制拒绝,且内容不受插件删除影响。
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "alice", Source: "manual", Ref: "m-1", Chunk: "manual 手册独占词"}); err != nil {
t.Fatal(err)
}
if err := h.KnowledgeDelete(ctx, "manual"); err == nil {
t.Fatal("delete(manual) must be rejected: plugins may only revoke their own prefix")
}
if hits = searchAll(t, pipe, "alice", "手册独占词"); len(hits) != 1 {
t.Fatalf("manual row must survive: hits=%d", len(hits))
}
}
+121 -5
View File
@@ -1,9 +1,12 @@
package app
import (
"agent-system-backend/pkg/extension"
"context"
"fmt"
"sync"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
@@ -15,14 +18,68 @@ import (
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
st *store.Store
pipe *rag.Pipeline
set *settings.Service
st *store.Store
pipe *rag.Pipeline
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
type projectAuthz struct {
mu sync.Mutex
holder string // 持有者 pluginID(首个注册者生效)
fn extension.ResourceAuthorizer
}
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
a.mu.Lock()
defer a.mu.Unlock()
if a.fn != nil {
return // 首个注册者生效;后来者忽略(调用方经日志可查)
}
a.holder, a.fn = pluginID, fn
}
// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。
func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) {
a.mu.Lock()
fn := a.fn
a.mu.Unlock()
if fn == nil || owner == "" || projectID == "" {
return false, nil
}
return fn(owner, projectID)
}
// wireScopedHostFactory EXT-11:把「按 owner+pluginID 构造受限 Host」注入公开面
// (extension.SetScopedHostFactory)。组合根在 hostcaps 就绪后调用;重建句柄与
// 分发期同构(owner 作用域、来源前缀强制、KV/任务台账隔离)。
func wireScopedHostFactory(caps hostcaps) {
extension.SetScopedHostFactory(func(owner, pluginID string) extension.Host {
return inprocHost{caps: caps, pluginID: pluginID, owner: owner}
})
}
// 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)。
@@ -39,6 +96,21 @@ func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, e
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
}
// maxParseInput 与上传端点的读取上限同口径(server.upload 的 100<<20)。
const maxParseInput = 100 << 20
// parse 走基座内置解析器(EXT-5):纯计算、不落 owner 数据;不可解析由
// Warnings 显式表达,只有取消与超限是 error。
func (c hostcaps) parse(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
if err := ctx.Err(); err != nil {
return core.ParsedDocument{}, err
}
if len(content) > maxParseInput {
return core.ParsedDocument{}, fmt.Errorf("parse input %d bytes exceeds %d limit", len(content), maxParseInput)
}
return ingest.Parse(filename, mime, content), nil
}
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
type inprocHost struct {
@@ -75,7 +147,7 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
//(embedding 失败降级纯全文,不阻断写入)。
// (embedding 失败降级纯全文,不阻断写入)。
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if len(docs) == 0 {
return nil
@@ -93,6 +165,44 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
}
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if len(docs) == 0 {
return nil
}
refs := map[string]bool{}
for i := range docs {
docs[i].Owner = core.ID(h.owner)
if docs[i].Source == "" {
docs[i].Source = "plugin:" + h.pluginID
}
if docs[i].Ref != "" {
refs[docs[i].Ref] = true
}
}
for ref := range refs {
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
return err
}
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
return err
}
}
return h.KnowledgeWrite(ctx, docs, embed)
}
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
if sourcePrefix != "plugin:"+h.pluginID {
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
}
_, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix)
return err
}
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
@@ -114,3 +224,9 @@ func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
return h.caps.st.ArtifactContent(h.owner, artifactID)
}
// ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储,
// owner 作用域由调用方经 ArtifactContent 取字节时保证。
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
return h.caps.parse(ctx, filename, mime, content)
}
+111
View File
@@ -1,6 +1,8 @@
package app
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"path/filepath"
@@ -193,3 +195,112 @@ func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) {
t.Fatalf("vector owner leak: %+v", bhits)
}
}
// TestInprocHostParseDocumentOwnerScope:EXT-5 解析方法的 owner 作用域——
// alice 经句柄读自己的工件并解析;bob 用同一工件 ID 在 ArtifactContent 处
// 即被拒(解析是纯计算,作用域边界在取原件一环)。
func TestInprocHostParseDocumentOwnerScope(t *testing.T) {
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
extension.RegisterCompiled(extension.InProc{
ID: "tst",
Register: func(reg extension.Registry) error {
return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.parse","title":"解析"}`),
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
id, _ := req.Params["artifactId"].(string)
content, err := h.ArtifactContent(id)
if err != nil {
return nil, err
}
doc, err := h.ParseDocument(ctx, "报价.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content)
if err != nil {
return nil, err
}
return map[string]any{
"parser": doc.Meta.Parser,
"warnings": len(doc.Warnings),
"blocks": len(doc.Blocks),
}, nil
})
},
})
bus, st := newInprocTestApp(t, nil)
art := core.Artifact{ProjectID: "p1", Owner: "alice", Name: "报价.xlsx", Kind: "document"}
if err := st.CreateArtifact(&art, testXLSXBytes(t)); err != nil {
t.Fatal(err)
}
artID := art.ID
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "alice", Entrance: "chat",
})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("alice dispatch: %+v %s", resp.Receipt, resp.Receipt.Reason)
}
m, _ := resp.Payload.(map[string]any)
if m["parser"] != "builtin:xlsx" || m["warnings"] != 0 || m["blocks"] == 0 {
t.Fatalf("alice payload: %+v", m)
}
// bob 同一工件 ID:取原件即被拒(跨 owner 不可见)。
resp = bus.Dispatch(context.Background(), cmdbus.Request{
ID: "tst.parse", Params: map[string]any{"artifactId": artID}, Owner: "bob", Entrance: "chat",
})
if resp.Receipt.State == agentcommand.StateCommitted {
t.Fatalf("bob must not read alice's artifact: %+v", resp.Payload)
}
}
// TestInprocHostParseDocumentLimits:仅有的两条 error 路径——ctx 取消与输入
// 超过 100MiB 上限;不可解析格式仍是带 Warnings 的显式失败。
func TestInprocHostParseDocumentLimits(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
h := inprocHost{caps: hostcaps{st: st}, pluginID: "tst", owner: "alice"}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := h.ParseDocument(ctx, "a.xlsx", "", []byte("x")); err == nil {
t.Fatal("canceled ctx must error")
}
if _, err := h.ParseDocument(context.Background(), "a.xlsx", "", make([]byte, maxParseInput+1)); err == nil {
t.Fatal("oversized input must error")
}
doc, err := h.ParseDocument(context.Background(), "旧.xls", "", []byte{0xD0, 0xCF})
if err != nil {
t.Fatalf("legacy format is a visible failure, not an error: %v", err)
}
if doc.Meta.Parser != "builtin:legacy-office" || len(doc.Warnings) != 1 {
t.Fatalf("legacy doc: %+v %v", doc.Meta, doc.Warnings)
}
}
// testXLSXBytes 构造最小可用 xlsx(单表两行),供句柄测试解析。
func testXLSXBytes(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
files := map[string]string{
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="报价" sheetId="1" r:id="rId1"/></sheets></workbook>`,
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`,
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
<row r="1"><c r="A1" t="inlineStr"><is><t>物料</t></is></c><c r="B1" t="inlineStr"><is><t>单价</t></is></c></row>
<row r="2"><c r="A2" t="inlineStr"><is><t>支架</t></is></c><c r="B2"><v>12.5</v></c></row>
</sheetData></worksheet>`,
}
for _, name := range []string{"xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/worksheets/sheet1.xml"} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(files[name]))
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
+44
View File
@@ -0,0 +1,44 @@
package app
import (
"testing"
"agent-system-backend/internal/settings"
core "agent-system-core"
)
// 慢通道开关默认关(行为与 I1 一致的机器证明):
// 两条 runtime.intentSemantic / runtime.intentGenerator 必须注册且默认 false。
func TestIntentSlowLaneFlagsDefaultOff(t *testing.T) {
seen := map[string]core.SettingDesc{}
for _, d := range settings.Schema() {
seen[d.Key] = d
}
for _, key := range []string{"runtime.intentSemantic", "runtime.intentGenerator"} {
d, ok := seen[key]
if !ok {
t.Fatalf("设置未注册:%s", key)
}
if d.Type != core.SettingBool {
t.Fatalf("%s 类型应为 bool,得到 %v", key, d.Type)
}
if def, isBool := d.Default.(bool); !isBool || def {
t.Fatalf("%s 必须默认 false,得到 %v", key, d.Default)
}
}
}
// 遥测开关默认安全(N3 WP4):otlpEndpoint 默认空(不导出,保持现状),
// serviceName 有非空默认。两处都注册进 Schema。
func TestTelemetryFlagsDefaults(t *testing.T) {
seen := map[string]core.SettingDesc{}
for _, d := range settings.Schema() {
seen[d.Key] = d
}
if d, ok := seen["telemetry.otlpEndpoint"]; !ok || d.Default != "" {
t.Fatalf("otlpEndpoint 必须注册且默认空:%+v", d)
}
if d, ok := seen["telemetry.serviceName"]; !ok || d.Default != "agent-system-backend" {
t.Fatalf("serviceName 必须注册且默认 agent-system-backend:%+v", d)
}
}
+57
View File
@@ -0,0 +1,57 @@
package app
import (
"context"
"path/filepath"
"testing"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
// EXT-11 红测:注入前 NewScopedHost fail-closed;注入后重建句柄与分发期同构
// (owner 作用域、PluginID、KV 隔离、知识删除前缀强制)。
func TestScopedHostFactory(t *testing.T) {
// 注入前:fail-closed。
if _, err := extension.NewScopedHost("alice", "rdm"); err == nil {
t.Fatal("before wiring, NewScopedHost must fail closed(工厂未注入)")
}
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
pipe := rag.NewPipeline(st, set, nil, nil)
caps := hostcaps{st: st, pipe: pipe, set: set}
// 组合根同款注入(app.Run 内以 wireScopedHostFactory 接线;此处直接调内部函数)。
wireScopedHostFactory(caps)
t.Cleanup(func() { extension.SetScopedHostFactory(nil) })
h, err := extension.NewScopedHost("alice", "rdm")
if err != nil {
t.Fatalf("NewScopedHost: %v", err)
}
if h.Owner() != "alice" || h.PluginID() != "rdm" {
t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID())
}
// KV 往返落 owner 作用域。
if err := h.KVPut("k1", "v1"); err != nil {
t.Fatal(err)
}
if v, ok, err := h.KVGet("k1"); err != nil || !ok || v != "v1" {
t.Fatalf("KVGet = %q ok=%v err=%v", v, ok, err)
}
// 同构性:知识删除前缀强制(manual 不可及)。
if err := h.KnowledgeDelete(context.Background(), "manual"); err == nil {
t.Fatal("scoped host must keep the plugin:<id> prefix enforcement")
}
// 空 owner/pluginID:拒绝。
if _, err := extension.NewScopedHost("", "rdm"); err == nil {
t.Fatal("empty owner must be rejected")
}
}
+14 -6
View File
@@ -27,15 +27,18 @@ var (
const cardAppendWindow = 250 * time.Millisecond
type Service struct {
st *store.Store
emit Emitter
now func() time.Time
emitMu sync.Mutex
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
st *store.Store
emit Emitter
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
MirrorConvCards bool
now func() time.Time
emitMu sync.Mutex
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
}
func New(st *store.Store, emit Emitter) *Service {
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}}
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true}
}
// emitCard 按卡归属发布卡片事件(D50):
@@ -52,6 +55,11 @@ func (s *Service) emitCard(c core.ActivityCard, typ core.StreamEventType, payloa
if err := s.emit(c.ConversationID, c.SourceRunID, typ, payload); err != nil {
return err
}
// EXT-12 最小开关:会话卡不镜像进项目公共流(不发布即不持久,
// 重放天然不含会话卡);项目级卡(无会话归属)不受影响。
if !s.MirrorConvCards {
return nil
}
}
if c.ProjectID != "" {
return s.emit(string(core.ProjectChannelKey(c.ProjectID)), c.SourceRunID, typ, payload)
+56
View File
@@ -0,0 +1,56 @@
package cardsvc
import (
"testing"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// EXT-12 最小开关(红测):MirrorConvCards=false 时会话卡不镜像进项目公共流
// (不发布即不持久,重放天然不含会话卡);项目级卡不受影响;缺省 true 保持现状。
func TestConvCardMirrorSwitch(t *testing.T) {
newSvc := func(mirror bool) (*Service, *[]string) {
chans := []string{}
emit := func(convID, runID string, typ core.StreamEventType, payload any) error {
chans = append(chans, convID)
return nil
}
st, err := store.OpenSQLite(t.TempDir() + "/cards.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
svc := New(st, emit)
svc.MirrorConvCards = mirror
return svc, &chans
}
card := core.ActivityCard{ConversationID: "c-priv", ProjectID: "p-1", Kind: core.CardKindProgress, Title: "私有过程卡"}
// 开(缺省现状):会话流 + 项目流双发。
svc, chans := newSvc(true)
if _, err := svc.Create("alice", card, ""); err != nil {
t.Fatal(err)
}
if len(*chans) != 2 {
t.Fatalf("mirror on: emitted %v, want 会话流+项目流", *chans)
}
// 关:只进本人会话流,项目公共流零事件。
svc2, chans2 := newSvc(false)
if _, err := svc2.Create("alice", card, ""); err != nil {
t.Fatal(err)
}
if len(*chans2) != 1 || (*chans2)[0] != "c-priv" {
t.Fatalf("mirror off: emitted %v, want 仅会话流 c-priv(项目流不得发布)", *chans2)
}
// 项目级卡(无会话归属)不受开关影响。
pc := core.ActivityCard{ProjectID: "p-1", Kind: core.CardKindProgress, Title: "项目级卡"}
if _, err := svc2.Create("alice", pc, ""); err != nil {
t.Fatal(err)
}
if len(*chans2) != 2 || (*chans2)[1] != string(core.ProjectChannelKey("p-1")) {
t.Fatalf("project-level card must still publish: %v", *chans2)
}
}
+51 -3
View File
@@ -18,9 +18,11 @@ import (
"agent-system-core"
)
// AuditStore 审计落盘端口(store 实现;v7 command_audit)。
// AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。
// owner 是请求租户(req.Owner):审计行按租户归属,查询按 owner 过滤,
// 跨租户审计不可见(信封 Actor 是发起主体,不作归属依据)。
type AuditStore interface {
AppendAudit(env agentcommand.AuditEnvelope) error
AppendAudit(owner string, env agentcommand.AuditEnvelope) error
}
// CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。
@@ -32,6 +34,37 @@ 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 工具投影源)。
func (b *Bus) CommandTools() []CommandTool {
out := []CommandTool{}
for _, r := range b.reg.List() {
if !r.Enabled || r.Spec.ExposeAsTool == nil {
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
@@ -67,6 +100,7 @@ type Bus struct {
pendMu sync.Mutex
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
@@ -82,6 +116,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(),
}
}
@@ -136,10 +171,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 {
@@ -149,9 +193,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))]
@@ -275,7 +323,7 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
if _, src, err := b.reg.Resolve(req.ID); err == nil {
env.Source = src
}
if err := b.audit.AppendAudit(env); err != nil {
if err := b.audit.AppendAudit(req.Owner, env); err != nil {
// 审计不可用:结果标记未知,不臆断成功(四态语义)。
return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()}
}
+1 -1
View File
@@ -20,7 +20,7 @@ type memAudit struct {
fail bool
}
func (m *memAudit) AppendAudit(env agentcommand.AuditEnvelope) error {
func (m *memAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error {
if m.fail {
return errors.New("audit down")
}
+74
View File
@@ -0,0 +1,74 @@
package cmdbus
// EXT-14:Bus.CommandTools 只投影带 ExposeAsTool 的已启用命令;risk 缺省 low、
// description 缺省取 title;未暴露/未启用的命令不出现。
import (
"context"
"encoding/json"
"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 }
+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
}
+31 -12
View File
@@ -26,6 +26,11 @@ type Config struct {
Instance string `json:"instance"`
InsecureListen bool `json:"insecureListen"`
AllowedHostsRaw string `json:"allowedHosts"`
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
DisableExecTools bool `json:"disableExecTools"`
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
// 隔离档)。DisableExecTools 优先级高于后端选择。
ExecBackend string `json:"execBackend"`
}
var defaults = Config{
@@ -37,16 +42,18 @@ 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",
"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",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
}
// Source 配置来源层级(数值越大优先级越高)。
@@ -165,6 +172,10 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.InsecureListen
case "allowedHosts":
dst = &c.AllowedHostsRaw
case "disableExecTools":
dst = &c.DisableExecTools
case "execBackend":
dst = &c.ExecBackend
default:
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
}
@@ -194,18 +205,22 @@ func stringField(c Config, f string) string {
return c.Instance
case "allowedHosts":
return c.AllowedHostsRaw
case "execBackend":
return c.ExecBackend
}
return ""
}
func isBoolField(f string) bool {
return f == "mock" || f == "insecureListen"
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
}
func boolField(c Config, f string) bool {
switch f {
case "insecureListen":
return c.InsecureListen
case "disableExecTools":
return c.DisableExecTools
default:
return c.Mock
}
@@ -215,6 +230,8 @@ func setBool(c *Config, f string, v bool) {
switch f {
case "insecureListen":
c.InsecureListen = v
case "disableExecTools":
c.DisableExecTools = v
default:
c.Mock = v
}
@@ -238,6 +255,8 @@ func setString(c *Config, f, v string) {
c.Instance = v
case "allowedHosts":
c.AllowedHostsRaw = v
case "execBackend":
c.ExecBackend = v
}
}
@@ -259,7 +278,7 @@ 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"} {
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend"} {
v := stringField(r.Config, f)
if isBoolField(f) {
v = fmt.Sprintf("%t", boolField(r.Config, f))
+32
View File
@@ -123,6 +123,38 @@ func TestEnvBool(t *testing.T) {
}
}
// disableExecTools:默认 false;文件、环境、CLI 三层均可打开,CLI 优先。
func TestDisableExecToolsLayers(t *testing.T) {
r, err := Load("", noEnv, Config{}, nil)
if err != nil || r.DisableExecTools {
t.Fatalf("default must be false: %v %+v", err, r.Config)
}
f := fileWith(t, `{"disableExecTools":true}`)
r, err = Load(f, noEnv, Config{}, nil)
if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcFile {
t.Fatalf("file layer: %v %+v src=%v", err, r.Config, r.SourceOf("disableExecTools"))
}
env := func(k string) (string, bool) {
if k == "AGENT_SYSTEM_DISABLE_EXEC_TOOLS" {
return "1", true
}
return "", false
}
r, err = Load("", env, Config{}, nil)
if err != nil || !r.DisableExecTools || r.SourceOf("disableExecTools") != SrcEnv {
t.Fatalf("env layer: %v %+v", err, r.Config)
}
r, err = Load("", env, Config{DisableExecTools: false}, map[string]bool{"disableExecTools": true})
if err != nil || r.DisableExecTools || r.SourceOf("disableExecTools") != SrcCLI {
t.Fatalf("cli must win: %v %+v", err, r.Config)
}
var out bytes.Buffer
r.Explain(&out, "")
if !bytes.Contains(out.Bytes(), []byte("disableExecTools")) {
t.Fatalf("explain must list disableExecTools:\n%s", out.String())
}
}
// explain:来源标注 + DSN 脱敏;只读(不落任何盘外文件)。
func TestExplainRedactsDSN(t *testing.T) {
env := func(k string) (string, bool) {
+50 -14
View File
@@ -36,13 +36,20 @@ 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
}
// 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...)
@@ -84,7 +91,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 +109,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 +135,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 +191,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 +229,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 +414,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 +437,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 打开时注入。
+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)
}
}
+33 -8
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 != "" {
@@ -207,12 +210,12 @@ func trimOldest(msgs []agentllm.Message, window, reserve int, rep *Report) []age
}
dropped := 0
for estimateAll(msgs) > limit {
idx := oldestDrop(msgs)
if idx < 0 {
start, end := oldestDropRange(msgs)
if start < 0 {
break
}
msgs = append(msgs[:idx], msgs[idx+1:]...)
dropped++
dropped += end - start
msgs = append(msgs[:start], msgs[end:]...)
}
if dropped > 0 {
rep.Truncations = append(rep.Truncations, fmtDropped(dropped))
@@ -226,9 +229,14 @@ func fmtDropped(n int) string {
func itoa(n int) string { return strconv.Itoa(n) }
func oldestDrop(msgs []agentllm.Message) int {
// oldestDropRange 返回下一次丢弃的区间 [start, end)(找不到返回 -1,-1)。
// 丢弃按工具组对齐:带 tool_calls 的 assistant 与其连续 tool 结果同退,
// 开头的孤立 tool 结果组整组同退——裁切后既不剩无结果的调用,
// 也不剩无调用的结果(与库层 alignToolTail 同语义、方向相反)。
// 组越过最后一条(当前轮)时不再丢弃:窗口允许略超,配对不可破坏。
func oldestDropRange(msgs []agentllm.Message) (int, int) {
if len(msgs) < 3 {
return -1
return -1, -1
}
// 保留开头的系统前缀和最后一条(当前轮)。
start := 0
@@ -236,9 +244,26 @@ func oldestDrop(msgs []agentllm.Message) int {
start++
}
if start >= len(msgs)-1 {
return -1
return -1, -1
}
return start
end := start + 1
m := msgs[start]
switch {
case m.Role == agentllm.RoleAssistant && len(m.ToolCalls) > 0:
// assistant 及其连续 tool 结果整组同退。
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
end++
}
case m.Role == agentllm.RoleTool:
// 开头即孤立 tool 结果(其 assistant 必已被丢或不存在):整组同退。
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
end++
}
}
if end > len(msgs)-1 {
return -1, -1
}
return start, end
}
func estimateAll(msgs []agentllm.Message) int {
+116 -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
@@ -62,3 +62,118 @@ func TestTrimDropsOldestHistory(t *testing.T) {
t.Fatal("expected truncation")
}
}
// ---- N2 WP1:裁切工具组对齐(验收② 产品侧机器证据)----
// assertNoOrphanToolPairs 双向断言:每个 tool_call 有结果,每个结果有调用。
func assertNoOrphanToolPairs(t *testing.T, msgs []agentllm.Message) {
t.Helper()
calls := map[string]bool{}
results := map[string]bool{}
for _, m := range msgs {
for _, c := range m.ToolCalls {
calls[c.ID] = true
}
if m.Role == agentllm.RoleTool && m.ToolCallID != "" {
results[m.ToolCallID] = true
}
}
for id := range calls {
if !results[id] {
t.Fatalf("孤立 tool_call:%s(无结果)", id)
}
}
for id := range results {
if !calls[id] {
t.Fatalf("悬空 tool 结果:%s(无调用)", id)
}
}
}
func toolMsgs() []agentllm.Message {
call := func(id, name string) agentllm.ToolCall {
return agentllm.ToolCall{ID: id, Name: name, Arguments: "{}"}
}
return []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧一", 300)},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc1", "read")}},
{Role: agentllm.RoleTool, ToolCallID: "tc1", Name: "read", Content: strings.Repeat("结果一", 200)},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧二", 300)},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc2", "grep"), call("tc3", "read")}},
{Role: agentllm.RoleTool, ToolCallID: "tc2", Name: "grep", Content: strings.Repeat("结果二", 200)},
{Role: agentllm.RoleTool, ToolCallID: "tc3", Name: "read", Content: strings.Repeat("结果三", 200)},
{Role: agentllm.RoleUser, Content: "现在"},
}
}
// 连续多轮裁切后配对仍成立:assistant 与其结果同退,不逐条拆散。
func TestTrimOldestKeepsToolPairs(t *testing.T) {
out, rep := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 1600})
assertNoOrphanToolPairs(t, out)
if len(rep.Truncations) == 0 {
t.Fatal("expected truncation")
}
if out[len(out)-1].Content != "现在" {
t.Fatal("当前轮必须保留")
}
// 窗口再压:旧二组也须整组同退,仍不出现孤立配对。
out2, _ := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 900})
assertNoOrphanToolPairs(t, out2)
}
// 开头即孤立 tool 结果组:整组同退(其 assistant 已不存在)。
func TestTrimOldestDropsLeadingOrphanToolRun(t *testing.T) {
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleTool, ToolCallID: "ghost", Name: "read", Content: strings.Repeat("孤", 500)},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧", 500)},
{Role: agentllm.RoleUser, Content: "现在"},
}
out, _ := FinishAssembly(msgs, &Report{}, Tail{Window: 700})
assertNoOrphanToolPairs(t, out)
for _, m := range out {
if m.ToolCallID == "ghost" {
t.Fatal("孤立结果组应整组丢弃")
}
}
}
// 工具组越过当前轮时不再丢弃:窗口可略超,配对不可破坏。
func TestTrimOldestStopsWhenGroupCrossesCurrentTurn(t *testing.T) {
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{{ID: "tc9", Name: "read", Arguments: "{}"}}},
{Role: agentllm.RoleTool, ToolCallID: "tc9", Name: "read", Content: strings.Repeat("结果", 900)},
}
start, end := oldestDropRange(msgs)
if start != -1 || end != -1 {
t.Fatalf("组越过当前轮应返回 -1,-1,得到 %d,%d", start, end)
}
}
// ---- N2 WP1:PrefixHash 稳定契约(同一输入 → 同一前缀哈希)----
func TestPrefixHashStableForIdenticalInput(t *testing.T) {
build := func() []agentllm.Message {
return []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "系统前缀A"},
{Role: agentllm.RoleSystem, Content: "系统前缀B"},
{Role: agentllm.RoleUser, Content: "历史一"},
{Role: agentllm.RoleAssistant, Content: "回复一"},
{Role: agentllm.RoleUser, Content: "现在"},
}
}
_, rep1 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
_, rep2 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
if rep1.PrefixHash == "" || rep1.PrefixHash != rep2.PrefixHash {
t.Fatalf("同一输入的前缀哈希必须一致:%q vs %q", rep1.PrefixHash, rep2.PrefixHash)
}
// 前缀变化必须改变哈希(钉住哈希语义,防退化为常量)。
other := build()
other[0].Content = "系统前缀X"
_, rep3 := FinishAssembly(other, &Report{}, Tail{Instructions: "个人指令"})
if rep3.PrefixHash == rep1.PrefixHash {
t.Fatal("前缀变化后哈希必须不同")
}
}
+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
+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)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
package hostapi
import "encoding/json"
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
+10 -2
View File
@@ -18,7 +18,10 @@ type Server struct {
Store *store.Store
Dispatch func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error
Search func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
Complete func(ctx context.Context, owner, prompt string) (string, error)
// SharedSearch EXT-8 余项:非 nil 时 search 端点改用之(组合根传入
// 「本人结果 + 适配器放行的共享追加」组合;nil=现状仅本人)。
SharedSearch func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error)
Complete func(ctx context.Context, owner, prompt string) (string, error)
}
func (s *Server) Handler() http.Handler {
@@ -143,7 +146,12 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
http.Error(w, "retriever unavailable", http.StatusServiceUnavailable)
return
}
hits, err := s.Search(r.Context(), s.owner(r), r.URL.Query().Get("q"))
// EXT-8 余项:共享版可用时整体切换(本人+共享追加)。
fn := s.Search
if s.SharedSearch != nil {
fn = s.SharedSearch
}
hits, err := fn(r.Context(), s.owner(r), r.URL.Query().Get("q"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
+49
View File
@@ -0,0 +1,49 @@
package hostapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
core "agent-system-core"
)
// EXT-8 余项:hostapi 检索面——SharedSearch 可用时整体切换(本人+适配器放行的
// 共享追加);不可用时维持现状(仅本人回调)。
func TestSharedSearchFace(t *testing.T) {
own := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Owner: "bob", Chunk: "本人命中"}}}
sharedHit := core.RetrievalHit{Doc: core.KnowledgeDoc{Owner: "alice", ProjectID: "prj-s", Chunk: "共享命中"}}
srv := &Server{Token: "t", Search: func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return own, nil
}}
run := func() int {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/host/v1/knowledge/search?q=x", nil)
req.Header.Set("Authorization", "Bearer "+srv.Token)
srv.Handler().ServeHTTP(rec, req)
return rec.Code
}
if code := run(); code != http.StatusOK {
t.Fatalf("plain search: %d", code)
}
srv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return append(own, sharedHit), nil
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/host/v1/knowledge/search?q=x", nil)
req.Header.Set("Authorization", "Bearer "+srv.Token)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("shared search: %d %s", rec.Code, rec.Body.String())
}
var hits []core.RetrievalHit
if err := jsonUnmarshal(rec.Body.Bytes(), &hits); err != nil {
t.Fatal(err)
}
if len(hits) != 2 || hits[1].Doc.Owner != "alice" {
t.Fatalf("hits = %+v, want 本人+共享", hits)
}
}
+171 -60
View File
@@ -4,7 +4,6 @@
package ingest
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
@@ -34,11 +33,16 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument {
doc.Meta.Confidence = 1.0
case "docx":
doc.Meta.Parser = "builtin:docx"
blocks, err := parseDocx(content)
if err != nil {
blocks, revised, err := parseDocxRevised(content)
switch {
case err != nil:
doc.Warnings = append(doc.Warnings, "docx 解析失败:"+err.Error())
doc.Meta.Confidence = 0.3
} else {
case revised:
doc.Blocks = blocks
doc.Warnings = append(doc.Warnings, docxRevisionWarning)
doc.Meta.Confidence = 0.85
default:
doc.Blocks = blocks
doc.Meta.Confidence = 0.92
}
@@ -52,6 +56,35 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument {
doc.Blocks = blocks
doc.Meta.Confidence = 0.85
}
case "xlsx", "xlsm":
doc.Meta.Parser = "builtin:xlsx"
blocks, warns := parseXlsx(content)
doc.Blocks = blocks
doc.Warnings = append(doc.Warnings, warns...)
switch {
case len(blocks) == 0:
doc.Meta.Confidence = 0.3
case len(warns) > 0:
doc.Meta.Confidence = 0.6 // 超限截断:内容可用但注明不完整
default:
doc.Meta.Confidence = 0.9
}
case "pptx", "pptm", "ppsx":
doc.Meta.Parser = "builtin:pptx"
blocks, warns := parsePptx(content)
doc.Blocks = blocks
doc.Warnings = append(doc.Warnings, warns...)
switch {
case len(blocks) == 0:
doc.Meta.Confidence = 0.3
case len(warns) > 0:
doc.Meta.Confidence = 0.6
default:
doc.Meta.Confidence = 0.9
}
case "doc", "xls", "ppt":
doc.Meta.Parser = "builtin:legacy-office"
doc.Warnings = append(doc.Warnings, "旧版二进制格式 ."+ext+" 不解析:请转存为 ."+legacyOfficeTarget(ext)+" 后重传")
default:
if isImage(ext) {
doc.Warnings = append(doc.Warnings, "图片不走文本解析(OCR 为 MinerU 深度档,远期)")
@@ -133,22 +166,73 @@ func parsePlain(s string) []core.DocBlock {
// ---- docx(OOXML:zip 内 word/document.xml)----
// xpar 是解码后的段落。文字按文档顺序从任意深度的 w:t 收集,w:ins、
// w:hyperlink、w:fldSimple、w:sdt 等包裹层内的文字因此不会丢;被删除或
// 移走的原文(w:del、w:moveFrom)、域代码(w:instrText)与兼容回退分支
// (mc:Fallback,与 mc:Choice 内容重复)不进正文。
type xpar struct {
Props struct {
Style struct {
Val string `xml:"val,attr"`
} `xml:"pStyle"`
} `xml:"pPr"`
Runs []xrun `xml:"r"`
Style string
Text string
Revised bool // 段内含修订标记(插入/删除/移动)
}
type xrun struct {
Text []struct {
Content string `xml:",chardata"`
} `xml:"t"`
Break struct {
Val string `xml:"val,attr"`
} `xml:"br"`
// docxSkipText 子树里的文字不属于当前正文。
var docxSkipText = map[string]bool{
"del": true, "moveFrom": true, "instrText": true, "delInstrText": true, "Fallback": true,
}
// docxRevision 出现即说明文档带修订。
var docxRevision = map[string]bool{"ins": true, "del": true, "moveFrom": true, "moveTo": true}
// UnmarshalXML 按 token 流遍历段落子树。按固定层级(p/r/t)映射只认 w:p
// 的直接子级 w:r,修订插入、超链接与域结果里的文字会被静默跳过。
func (p *xpar) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var sb strings.Builder
skip := 0
for {
tok, err := d.Token()
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
name := t.Name.Local
if docxRevision[name] {
p.Revised = true
}
switch {
case docxSkipText[name]:
skip++
case skip > 0:
case name == "pStyle" && p.Style == "":
p.Style = attrAnyNS(t, "val")
case name == "t":
var s string
if err := d.DecodeElement(&s, &t); err != nil {
return err
}
sb.WriteString(s)
}
case xml.EndElement:
if docxSkipText[t.Name.Local] && skip > 0 {
skip--
}
if t.Name == start.Name {
p.Text = sb.String()
return nil
}
}
}
}
// attrAnyNS 按本地名取属性值(w:val 与无前缀 val 都算)。
func attrAnyNS(st xml.StartElement, name string) string {
for _, a := range st.Attr {
if a.Name.Local == name {
return a.Value
}
}
return ""
}
type xtbl struct {
@@ -164,97 +248,108 @@ type xtc struct {
}
func parseDocx(content []byte) ([]core.DocBlock, error) {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
blocks, _, err := parseDocxRevised(content)
return blocks, err
}
// parseDocxRevised 同 parseDocx,另报告文档是否带修订标记。
func parseDocxRevised(content []byte) ([]core.DocBlock, bool, error) {
zr, err := openZip(content)
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
return nil, false, fmt.Errorf("open zip: %w", err)
}
var docXML []byte
for _, f := range zr.File {
if f.Name == "word/document.xml" {
rc, err := f.Open()
if err != nil {
return nil, err
}
docXML, _ = io.ReadAll(rc)
rc.Close()
break
}
}
if docXML == nil {
return nil, fmt.Errorf("word/document.xml not found")
docXML, err := zipPart(zr, "word/document.xml")
if err != nil {
return nil, false, err
}
return parseDocxBody(docXML)
}
// docxRevisionWarning 文档带修订时的显式告警(SR-A5:不静默)。
const docxRevisionWarning = "docx 含修订标记:正文按「接受全部修订」提取(插入保留、删除不计入);审阅修订过程请查看原件"
// parseDocxBody 按文档顺序解码 body 的段落与表格:token 流上遇到 p/tbl 就地
// DecodeElement(整元素消费,单元格内嵌 p 不会重复入列),表格与前后段落保持原位。
func parseDocxBody(docXML []byte) ([]core.DocBlock, error) {
// 段落可能包在 w:ins、w:sdt 等容器里,所以任意深度的 p 与 tbl 都识别;
// 返回值 revised 报告是否遇到修订标记。
func parseDocxBody(docXML []byte) (blocks []core.DocBlock, revised bool, err error) {
dec := xml.NewDecoder(bytes.NewReader(docXML))
var blocks []core.DocBlock
skip := 0
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("decode document.xml: %w", err)
return nil, revised, fmt.Errorf("decode document.xml: %w", err)
}
if end, ok := tok.(xml.EndElement); ok {
if docxSkipText[end.Name.Local] && skip > 0 {
skip--
}
continue
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch start.Name.Local {
name := start.Name.Local
if docxRevision[name] {
revised = true
}
if docxSkipText[name] {
skip++
continue
}
if skip > 0 {
continue
}
switch name {
case "p":
var p xpar
if err := dec.DecodeElement(&p, &start); err != nil {
return nil, fmt.Errorf("decode paragraph: %w", err)
return nil, revised, fmt.Errorf("decode paragraph: %w", err)
}
text := paragraphText(p)
if strings.TrimSpace(text) == "" {
revised = revised || p.Revised
if strings.TrimSpace(p.Text) == "" {
continue
}
if lvl, ok := headingLevel(p.Props.Style.Val); ok {
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text})
if lvl, ok := headingLevel(p.Style); ok {
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: p.Text})
} else {
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text})
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: p.Text})
}
case "tbl":
var tbl xtbl
if err := dec.DecodeElement(&tbl, &start); err != nil {
return nil, fmt.Errorf("decode table: %w", err)
return nil, revised, fmt.Errorf("decode table: %w", err)
}
if rows := tableRows(tbl); len(rows) > 0 {
rows, tblRevised := tableRows(tbl)
revised = revised || tblRevised
if len(rows) > 0 {
blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows})
}
}
}
return blocks, nil
return blocks, revised, nil
}
func tableRows(tbl xtbl) [][]string {
func tableRows(tbl xtbl) ([][]string, bool) {
var rows [][]string
revised := false
for _, tr := range tbl.Rows {
row := []string{}
for _, tc := range tr.Cells {
cell := []string{}
for _, p := range tc.Paragraphs {
cell = append(cell, paragraphText(p))
revised = revised || p.Revised
cell = append(cell, p.Text)
}
row = append(row, strings.Join(cell, " "))
}
rows = append(rows, row)
}
return rows
}
func paragraphText(p xpar) string {
var sb strings.Builder
for _, r := range p.Runs {
for _, t := range r.Text {
sb.WriteString(t.Content)
}
}
return sb.String()
return rows, revised
}
func headingLevel(style string) (int, bool) {
@@ -322,13 +417,29 @@ func isMostlyPrintable(s string) bool {
return nonPrint*10 < len(s)
}
// legacyOfficeTarget 旧版二进制 Office 格式的现代等价格式(提示转存用)。
func legacyOfficeTarget(ext string) string {
switch ext {
case "doc":
return "docx"
case "xls":
return "xlsx"
case "ppt":
return "pptx"
}
return ext
}
// ParsersDescription 返回解析器注册表描述(来源标记;设置页/文档用)。
func ParsersDescription() []core.ParserDesc {
return []core.ParserDesc{
{ID: "builtin:md", Formats: []string{"md", "markdown"}, Source: core.SourceBuiltin},
{ID: "builtin:txt", Formats: []string{"txt", "csv", "log"}, Source: core.SourceBuiltin},
{ID: "builtin:txt", Formats: []string{"txt", "text", "csv", "log"}, Source: core.SourceBuiltin},
{ID: "builtin:docx", Formats: []string{"docx"}, Source: core.SourceBuiltin},
{ID: "builtin:pdf", Formats: []string{"pdf"}, Source: core.SourceBuiltin, Note: "文本层;扫描件为远期 MinerU 深度档"},
{ID: "builtin:xlsx", Formats: []string{"xlsx", "xlsm"}, Source: core.SourceBuiltin},
{ID: "builtin:pptx", Formats: []string{"pptx", "pptm", "ppsx"}, Source: core.SourceBuiltin},
{ID: "builtin:legacy-office", Formats: []string{"doc", "xls", "ppt"}, Source: core.SourceBuiltin, Note: "不解析;提示转存现代格式后重传"},
{ID: "mineru", Formats: []string{"pdf", "png", "jpg"}, Source: core.SourcePlugin, Note: "远期可选:深度解析与交叉验证(默认不部署)"},
}
}
+62
View File
@@ -112,6 +112,68 @@ func TestParseDocx(t *testing.T) {
}
}
// docxWith 把 body 片段包成最小 docx(zip 内仅 word/document.xml)。
func docxWith(t *testing.T, body string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, err := zw.Create("word/document.xml")
if err != nil {
t.Fatal(err)
}
f.Write([]byte(`<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>` + body + `</w:body></w:document>`))
zw.Close()
return buf.Bytes()
}
// 修订与超链接:插入/超链接/域结果内的文字必须保留;删除文字不进正文;
// 含修订时显式告警(SR-A5:禁止静默降级)。
func TestParseDocxTrackedChangesAndHyperlinks(t *testing.T) {
body := `<w:p><w:r><w:t>周期</w:t></w:r><w:ins w:id="1" w:author="a"><w:r><w:t>18</w:t></w:r></w:ins>` +
`<w:del w:id="2" w:author="a"><w:r><w:delText>12</w:delText></w:r></w:del><w:r><w:t>个月</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>见</w:t></w:r><w:hyperlink r:id="rId5"><w:r><w:t>附件A</w:t></w:r></w:hyperlink></w:p>` +
`<w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>3</w:t></w:r></w:fldSimple><w:r><w:t>页</w:t></w:r></w:p>` +
`<w:tbl><w:tr><w:tc><w:p><w:ins w:id="3" w:author="a"><w:r><w:t>新增项</w:t></w:r></w:ins></w:p></w:tc></w:tr></w:tbl>`
doc := Parse("修订稿.docx", "", docxWith(t, body))
var paras []string
var cell string
for _, b := range doc.Blocks {
switch b.Type {
case core.BlockPara:
paras = append(paras, b.Text)
case core.BlockTable:
if len(b.Rows) > 0 && len(b.Rows[0]) > 0 {
cell = b.Rows[0][0]
}
}
}
want := []string{"周期18个月", "见附件A", "3页"}
if strings.Join(paras, "|") != strings.Join(want, "|") {
t.Errorf("paragraphs: got %q want %q", paras, want)
}
if cell != "新增项" {
t.Errorf("table cell inside w:ins: got %q", cell)
}
if strings.Contains(doc.PlainText(), "12个月") {
t.Errorf("deleted text leaked into body: %q", doc.PlainText())
}
warned := false
for _, w := range doc.Warnings {
warned = warned || strings.Contains(w, "修订")
}
if !warned {
t.Errorf("tracked changes must be reported in warnings, got %v", doc.Warnings)
}
}
// 无修订的普通文档不产生修订告警(避免噪音)。
func TestParseDocxNoRevisionNoWarning(t *testing.T) {
doc := Parse("干净.docx", "", docxWith(t, `<w:p><w:r><w:t>正文</w:t></w:r></w:p>`))
if len(doc.Warnings) != 0 {
t.Errorf("clean docx warnings: %v", doc.Warnings)
}
}
func TestParsePDFInvalid(t *testing.T) {
doc := Parse("scan.pdf", "application/pdf", []byte("not a pdf"))
if len(doc.Warnings) == 0 || doc.Meta.Confidence > 0.5 {
+140
View File
@@ -0,0 +1,140 @@
package ingest
// OOXML 公共件:zip 部件读取(解压上限防 zip 炸弹)、关系部件解析、
// r:id 属性与相对路径归一。docx/xlsx/pptx 解析器共用;纯标准库。
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path"
"strings"
)
// ooxmlPartLimit 单个 zip 部件的解压上限(测试可覆盖)。
var ooxmlPartLimit int64 = 64 << 20
func openZip(content []byte) (*zip.Reader, error) {
return zip.NewReader(bytes.NewReader(content), int64(len(content)))
}
// zipPart 按名称读取部件;解压超过 ooxmlPartLimit 视为异常拒绝。
func zipPart(zr *zip.Reader, name string) ([]byte, error) {
for _, f := range zr.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
b, err := io.ReadAll(io.LimitReader(rc, ooxmlPartLimit+1))
rc.Close()
if err != nil {
return nil, err
}
if int64(len(b)) > ooxmlPartLimit {
return nil, fmt.Errorf("part %s exceeds %d bytes decompression limit", name, ooxmlPartLimit)
}
return b, nil
}
return nil, fmt.Errorf("part %s not found", name)
}
// relsPath 计算部件对应的关系部件名(xl/workbook.xml → xl/_rels/workbook.xml.rels)。
func relsPath(part string) string {
dir, base := path.Split(part)
return dir + "_rels/" + base + ".rels"
}
type ooxmlRel struct {
ID, Target, Type string
}
// parseRels 解析部件的关系表;无关系部件返回空表(不是错误)。
func parseRels(zr *zip.Reader, part string) (map[string]ooxmlRel, error) {
data, err := zipPart(zr, relsPath(part))
if err != nil {
return map[string]ooxmlRel{}, nil
}
dec := xml.NewDecoder(bytes.NewReader(data))
out := map[string]ooxmlRel{}
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "Relationship" {
continue
}
r := ooxmlRel{}
for _, a := range st.Attr {
switch a.Name.Local {
case "Id":
r.ID = a.Value
case "Target":
r.Target = a.Value
case "Type":
r.Type = a.Value
}
}
if r.ID != "" {
out[r.ID] = r
}
}
return out, nil
}
// resolveRelTarget 把关系 target 解析为 zip 部件名:绝对 target(/xl/…)去
// 前导斜杠;相对 target 相对 partDir;归一化 ../ 与 ./ 段。
func resolveRelTarget(partDir, target string) string {
t := target
if !strings.HasPrefix(target, "/") {
t = partDir + "/" + target
}
var seg []string
for _, s := range strings.Split(t, "/") {
switch s {
case "", ".":
case "..":
if len(seg) > 0 {
seg = seg[:len(seg)-1]
}
default:
seg = append(seg, s)
}
}
return strings.Join(seg, "/")
}
// ridAttr 取 r:id:局部名 id 且带命名空间前缀(Transitional 与 Strict OOXML
// 的关系引用都满足该形态;无前缀的 id 是别的编号,不误取)。
func ridAttr(st xml.StartElement) string {
for _, a := range st.Attr {
if a.Name.Local == "id" && a.Name.Space != "" {
return a.Value
}
}
return ""
}
// attrLocal 取无命名空间前缀的属性值。
func attrLocal(st xml.StartElement, name string) string {
for _, a := range st.Attr {
if a.Name.Local == name && a.Name.Space == "" {
return a.Value
}
}
return ""
}
// relIsType 判断关系类型是否以标准关系类型后缀结尾(/worksheet、/slide 等)。
func relIsType(rel ooxmlRel, suffix string) bool {
return strings.HasSuffix(rel.Type, suffix)
}
+140
View File
@@ -0,0 +1,140 @@
package ingest
// 真实生成器轮测:openpyxl / python-pptx 现场生成 xlsx/pptx 再解析,防止
// 手工夹具只验证"自己写的 XML"。生成器缺失时 t.Skip(诚实出口);CI 已装
// 这两个包(.gitea/workflows/ci.yaml 安装 python-docx python-pptx openpyxl)。
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
func pythonModuleAvailable(t *testing.T, module string) bool {
t.Helper()
if _, err := exec.LookPath("python3"); err != nil {
return false
}
return exec.Command("python3", "-c", "import "+module).Run() == nil
}
// runPythonScript 把脚本写到临时目录执行;失败即 t.Fatal(脚本属测试自身)。
func runPythonScript(t *testing.T, script string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "gen.py")
if err := os.WriteFile(path, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command("python3", path).CombinedOutput()
if err != nil {
t.Fatalf("python generator failed: %v\n%s", err, out)
}
}
func TestParseXlsxFromOpenpyxl(t *testing.T) {
if !pythonModuleAvailable(t, "openpyxl") {
t.Skip("python3 openpyxl not available")
}
dir := t.TempDir()
file := filepath.Join(dir, "报价单.xlsx")
runPythonScript(t, `
import openpyxl
from datetime import datetime
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "报价"
ws.append(["物料", "单价", "日期"])
ws.append(["支架", 12.5, datetime(2024, 1, 1)])
ws["C2"].number_format = "yyyy-mm-dd"
ws["D2"] = "=A2&\"-x\""
ws["D3"] = True
wb.create_sheet("说明").append(["备注行"])
wb.save(r"`+file+`")
`)
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
doc := Parse(file, "", data)
if doc.Meta.Parser != "builtin:xlsx" || len(doc.Warnings) != 0 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
var heads []string
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
if len(heads) != 2 || heads[0] != "报价" || heads[1] != "说明" {
t.Fatalf("headings: %v", heads)
}
plain := doc.PlainText()
for _, want := range []string{"物料", "支架", "12.5", "2024-01-01"} {
if !strings.Contains(plain, want) {
t.Errorf("missing %q in: %s", want, plain)
}
}
// openpyxl 公式不带缓存值:回显公式本身(可检索、不冒充结果)。
if !strings.Contains(plain, "=A2") {
t.Errorf("formula without cache should echo: %s", plain)
}
if !strings.Contains(plain, "TRUE") {
t.Errorf("boolean cell should render TRUE: %s", plain)
}
}
func TestParsePptxFromPythonPptx(t *testing.T) {
if !pythonModuleAvailable(t, "pptx") {
t.Skip("python3 pptx not available")
}
dir := t.TempDir()
file := filepath.Join(dir, "方案评审.pptx")
runPythonScript(t, `
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
s1 = prs.slides.add_slide(prs.slide_layouts[0])
s1.shapes.title.text = "评审结论"
s1.placeholders[1].text = "通过"
s2 = prs.slides.add_slide(prs.slide_layouts[5])
s2.shapes.title.text = "报价明细"
tbl = s2.shapes.add_table(2, 2, Inches(1), Inches(2), Inches(6), Inches(1)).table
tbl.cell(0, 0).text = "物料"
tbl.cell(0, 1).text = "单价"
tbl.cell(1, 0).text = "支架"
tbl.cell(1, 1).text = "12.5"
notes = s1.notes_slide.notes_text_frame
notes.text = "需要补充法规依据"
prs.save(r"`+file+`")
`)
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
doc := Parse(file, "", data)
if doc.Meta.Parser != "builtin:pptx" || len(doc.Warnings) != 0 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
var heads []string
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
if len(heads) != 2 || !strings.HasPrefix(heads[0], "幻灯片 1:评审结论") || !strings.HasPrefix(heads[1], "幻灯片 2:报价明细") {
t.Fatalf("headings: %v", heads)
}
plain := doc.PlainText()
for _, want := range []string{"通过", "物料", "支架", "12.5", "备注:需要补充法规依据"} {
if !strings.Contains(plain, want) {
t.Errorf("missing %q in: %s", want, plain)
}
}
}
+432
View File
@@ -0,0 +1,432 @@
package ingest
// OOXML(xlsx/pptx)解析器测试:手工构造最小 zip 部件,覆盖顺序解析、富文本、
// 公式、日期、稀疏列、隐藏表、组合形状、AlternateContent、表格合并、备注、
// 上限与失败路径。真实生成器的轮测见 ooxml_python_test.go。
import (
"archive/zip"
"bytes"
"sort"
"strings"
"testing"
core "agent-system-core"
)
// zipBytes 按名称构造 zip(键排序保证确定性)。
func zipBytes(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
names := make([]string, 0, len(files))
for n := range files {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
w, err := zw.Create(n)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(files[n])); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
const relTypeNS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
func xlsxFixture() map[string]string {
return map[string]string{
"xl/workbook.xml": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="` + relTypeNS + `">
<sheets>
<sheet name="报价" sheetId="1" r:id="rId1"/>
<sheet name="草稿" sheetId="2" state="hidden" r:id="rId2"/>
</sheets></workbook>`,
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="` + relTypeNS + `/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="` + relTypeNS + `/worksheet" Target="/xl/worksheets/sheet2.xml"/>
</Relationships>`,
// shared[0]=物料;shared[1]=富文本"单价"(rPh 注音必须排除)。
"xl/sharedStrings.xml": `<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<si><t>物料</t></si>
<si><rPh sb="0" eb="2"><t>たんか</t></rPh><r><rPr lang="ja"/><t>单价</t></r></si>
</sst>`,
"xl/styles.xml": `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<numFmts><numFmt numFmtId="164" formatCode="yyyy&quot;年&quot;m&quot;月&quot;d&quot;日&quot;"/></numFmts>
<cellXfs count="3">
<xf numFmtId="0"/>
<xf numFmtId="14" applyNumberFormat="1"/>
<xf numFmtId="164" applyNumberFormat="1"/>
</cellXfs></styleSheet>`,
// A1=物料 B1=单价(富文本) A2=公式缓存 10 B2=无缓存公式 C2=日期(内置14)=2024-01-01
// D2=TRUE E2=#DIV/0! F2=内联 G2=自定义日期格式(2024-01-02) 稀疏行 C4=物料。
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>
<row r="2"><c r="A2"><f>B9*2</f><v>10</v></c><c r="B2"><f>B9*3</f></c><c r="C2" s="1"><v>45292</v></c><c r="D2" t="b"><v>1</v></c><c r="E2" t="e"><v>#DIV/0!</v></c><c r="F2" t="inlineStr"><is><t>内联</t></is></c><c r="G2" s="2"><v>45293</v></c></row>
<row r="4"><c r="C4" t="s"><v>0</v></c></row>
</sheetData></worksheet>`,
"xl/worksheets/sheet2.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1"><c r="A1"><v>7</v></c></row></sheetData></worksheet>`,
}
}
func findTable(doc core.ParsedDocument, after int) *core.DocBlock {
for i := after; i < len(doc.Blocks); i++ {
if doc.Blocks[i].Type == core.BlockTable {
return &doc.Blocks[i]
}
}
return nil
}
func TestParseXlsxBuiltFromParts(t *testing.T) {
doc := Parse("报价单.xlsx", "", zipBytes(t, xlsxFixture()))
if doc.Meta.Parser != "builtin:xlsx" || len(doc.Warnings) != 0 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
if doc.Meta.Confidence != 0.9 {
t.Fatalf("confidence: %v", doc.Meta.Confidence)
}
// 工作表顺序取 workbook.xml(报价在前),隐藏表标注;绝对 target /xl/… 也能定位。
if doc.Blocks[0].Type != core.BlockHeading || doc.Blocks[0].Text != "报价" {
t.Fatalf("first block: %+v", doc.Blocks[0])
}
heads := []string{}
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
if strings.Join(heads, "|") != "报价|草稿(隐藏)" {
t.Fatalf("headings: %v", heads)
}
tbl := findTable(doc, 0)
if tbl == nil || len(tbl.Rows) != 3 {
t.Fatalf("table: %+v", tbl)
}
row1, row2, row4 := tbl.Rows[0], tbl.Rows[1], tbl.Rows[2]
if row1[0] != "物料" || row1[1] != "单价" {
t.Errorf("header row (phonetic must be excluded): %v", row1)
}
want := []string{"10", "=B9*3", "2024-01-01", "TRUE", "#DIV/0!", "内联", "2024-01-02"}
if len(row2) != len(want) {
t.Fatalf("row2: %v", row2)
}
for i, w := range want {
if row2[i] != w {
t.Errorf("row2[%d]: got %q want %q", i, row2[i], w)
}
}
// 稀疏单元格按引用就位:C4 → 第三列。
if len(row4) != 3 || row4[0] != "" || row4[1] != "" || row4[2] != "物料" {
t.Errorf("sparse row: %v", row4)
}
if !strings.Contains(doc.PlainText(), "2024-01-01") {
t.Error("PlainText missing date text")
}
}
func TestParseXlsxEmptySheetSkipped(t *testing.T) {
files := xlsxFixture()
files["xl/worksheets/sheet2.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"/></sheetData></worksheet>`
doc := Parse("x.xlsx", "", zipBytes(t, files))
heads := 0
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads++
}
}
if heads != 1 || len(doc.Warnings) != 0 {
t.Fatalf("empty sheet must be skipped: blocks=%+v warnings=%v", doc.Blocks, doc.Warnings)
}
}
func TestParseXlsxInvalid(t *testing.T) {
doc := Parse("坏.xlsx", "", []byte("not a zip"))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
t.Fatalf("invalid xlsx should fail visibly: %+v %v", doc.Meta, doc.Warnings)
}
files := xlsxFixture()
delete(files, "xl/workbook.xml")
doc = Parse("无工作簿.xlsx", "", zipBytes(t, files))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 {
t.Fatalf("missing workbook.xml should fail visibly: %v", doc.Warnings)
}
}
func TestParseXlsxCellCap(t *testing.T) {
old := xlsxMaxCells
xlsxMaxCells = 3
defer func() { xlsxMaxCells = old }()
doc := Parse("超限.xlsx", "", zipBytes(t, xlsxFixture()))
if len(doc.Blocks) == 0 {
t.Fatal("truncated parse should keep partial blocks")
}
if len(doc.Warnings) == 0 || doc.Meta.Confidence != 0.6 {
t.Fatalf("truncation must warn and lower confidence: %v %v", doc.Meta.Confidence, doc.Warnings)
}
if !strings.Contains(strings.Join(doc.Warnings, ";"), "上限") {
t.Errorf("warnings: %v", doc.Warnings)
}
}
func TestParseXlsxPartSizeCap(t *testing.T) {
old := ooxmlPartLimit
ooxmlPartLimit = 16
defer func() { ooxmlPartLimit = old }()
doc := Parse("炸弹.xlsx", "", zipBytes(t, xlsxFixture()))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
t.Fatalf("oversized part must fail visibly: %+v %v", doc.Meta, doc.Warnings)
}
}
func TestParseXlsxDate1904(t *testing.T) {
files := xlsxFixture()
files["xl/workbook.xml"] = strings.Replace(files["xl/workbook.xml"],
"<sheets>", `<workbookPr date1904="1"/><sheets>`, 1)
files["xl/worksheets/sheet1.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1"><c r="A1" s="1"><v>1</v></c><c r="B1" s="1"><v>0.520833333333333</v></c></row></sheetData></worksheet>`
doc := Parse("mac.xlsx", "", zipBytes(t, files))
tbl := findTable(doc, 0)
// 1904 历法:serial 0 = 1904-01-01,serial 1 = 1904-01-02。
if tbl == nil || tbl.Rows[0][0] != "1904-01-02" {
t.Fatalf("1904 epoch: %+v", tbl)
}
if !strings.Contains(tbl.Rows[0][1], "1904-01-01 12:30") {
t.Errorf("time fraction: %q", tbl.Rows[0][1])
}
}
const pNS = "http://schemas.openxmlformats.org/presentationml/2006/main"
const aNS = "http://schemas.openxmlformats.org/drawingml/2006/main"
func sp(phType, text string) string {
ph := ""
if phType != "" {
ph = `<p:nvPr><p:ph type="` + phType + `"/></p:nvPr>`
} else {
ph = `<p:nvPr/>`
}
return `<p:sp><p:nvSpPr><p:cNvPr id="2" name="S"/><p:cNvSpPr/></p:nvSpPr>` + ph + `<p:spPr/>` +
`<p:txBody><a:bodyPr/><a:p><a:r><a:t>` + text + `</a:t></a:r></a:p></p:txBody></p:sp>`
}
func pptxFixture() map[string]string {
presRel := func(id, target string) string {
return `<Relationship Id="` + id + `" Type="` + relTypeNS + `/slide" Target="` + target + `"/>`
}
slide1 := `<p:sld xmlns:p="` + pNS + `" xmlns:a="` + aNS + `" xmlns:r="` + relTypeNS + `"><p:cSld><p:spTree>
<p:nvGrpSpPr/><p:grpSpPr/>
` + sp("title", "封面标题") + `
` + sp("body", "") + `
<p:sp><p:nvSpPr><p:cNvPr id="4" name="B"/><p:cNvSpPr/></p:nvSpPr><p:nvPr/><p:spPr/><p:txBody><a:bodyPr/>
<a:p><a:r><a:t>第一行</a:t></a:r><a:br/><a:r><a:t>第二行</a:t></a:r></a:p></p:txBody></p:sp>
<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>` + sp("", "组合内文本") + `</p:grpSp>
<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
<mc:Choice Requires="p14">` + sp("", "选内文本") + `</mc:Choice>
<mc:Fallback>` + sp("", "弃内文本") + `</mc:Fallback>
</mc:AlternateContent>
<p:sp><p:nvSpPr><p:cNvPr id="6" name="N"/><p:cNvSpPr/></p:nvSpPr><p:nvPr><p:ph type="sldNum"/></p:nvPr><p:spPr/><p:txBody><a:p><a:fld id="{F1}"><a:t>3</a:t></a:fld></a:p></p:txBody></p:sp>
<p:graphicFrame><p:nvGraphicFramePr/><p:xfrm/><a:graphic><a:graphicData uri="` + aNS + `/table"><a:tbl>
<a:tr h="1"><a:tc gridSpan="2"><a:txBody><a:p><a:r><a:t>合并头</a:t></a:r></a:p></a:txBody></a:tc><a:tc><a:txBody><a:p><a:r><a:t>列三</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
<a:tr h="1"><a:tc hMerge="1"><a:txBody><a:p/></a:txBody></a:tc><a:tc hMerge="1"><a:txBody><a:p/></a:txBody></a:tc><a:tc><a:txBody><a:p><a:r><a:t>值三</a:t></a:r></a:p></a:txBody></a:tc></a:tr>
</a:tbl></a:graphicData></a:graphic></p:graphicFrame>
</p:spTree></p:cSld></p:sld>`
slide2 := `<p:sld xmlns:p="` + pNS + `" xmlns:a="` + aNS + `"><p:cSld><p:spTree>
<p:nvGrpSpPr/><p:grpSpPr/>` + sp("body", "第二页要点") + `
</p:spTree></p:cSld></p:sld>`
return map[string]string{
"ppt/presentation.xml": `<p:presentation xmlns:p="` + pNS + `" xmlns:r="` + relTypeNS + `"><p:sldIdLst>
<p:sldId id="256" r:id="rIdA"/><p:sldId id="257" r:id="rIdB"/>
</p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
presRel("rIdA", "slides/slide1.xml") + presRel("rIdB", "slides/slide2.xml") + `</Relationships>`,
"ppt/slides/slide1.xml": slide1,
"ppt/slides/slide2.xml": slide2,
"ppt/slides/_rels/slide1.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="` + relTypeNS + `/notesSlide" Target="../notesSlides/notesSlide1.xml"/></Relationships>`,
// slide2 的备注故意缺失(无 rels 文件)。
"ppt/notesSlides/notesSlide1.xml": `<p:notes xmlns:p="` + pNS + `" xmlns:a="` + aNS + `"><p:cSld><p:spTree>
<p:nvGrpSpPr/><p:grpSpPr/>` + sp("body", "补充说明") + `
</p:spTree></p:cSld></p:notes>`,
}
}
func TestParsePptxBuiltFromParts(t *testing.T) {
doc := Parse("演示.pptx", "", zipBytes(t, pptxFixture()))
if doc.Meta.Parser != "builtin:pptx" || len(doc.Warnings) != 0 || doc.Meta.Confidence != 0.9 {
t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
}
var heads []string
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
// sldIdLst 顺序:slide1(有标题)在前;slide2 无标题占位符 → 无标题兜底。
if len(heads) != 2 || heads[0] != "幻灯片 1:封面标题" || heads[1] != "幻灯片 2" {
t.Fatalf("headings: %v", heads)
}
plain := doc.PlainText()
for _, want := range []string{"第一行\n第二行", "组合内文本", "选内文本", "备注:补充说明"} {
if !strings.Contains(plain, want) {
t.Errorf("missing %q in plain text", want)
}
}
if strings.Contains(plain, "弃内文本") {
t.Error("mc:Fallback content must be skipped")
}
// 页码占位符与字段文本不出现。
if strings.Contains(plain, "\n3\n") {
t.Error("slide number placeholder text leaked")
}
// 表格:gridSpan 占位 + hMerge 续格留空,保持三列对齐。
tbl := findTable(doc, 0)
if tbl == nil || len(tbl.Rows) != 2 {
t.Fatalf("table: %+v", tbl)
}
if strings.Join(tbl.Rows[0], "|") != "合并头||列三" {
t.Errorf("row1: %v", tbl.Rows[0])
}
if strings.Join(tbl.Rows[1], "|") != "||值三" {
t.Errorf("row2: %v", tbl.Rows[1])
}
}
func TestParsePptxInvalidAndEmpty(t *testing.T) {
doc := Parse("坏.pptx", "", []byte("garbage"))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 || doc.Meta.Confidence != 0.3 {
t.Fatalf("invalid pptx: %+v %v", doc.Meta, doc.Warnings)
}
files := pptxFixture()
files["ppt/presentation.xml"] = `<p:presentation xmlns:p="` + pNS + `"></p:presentation>`
doc = Parse("空演示.pptx", "", zipBytes(t, files))
if len(doc.Warnings) == 0 || len(doc.Blocks) != 0 {
t.Fatalf("no slides: %v", doc.Warnings)
}
}
func TestParseLegacyOfficeHint(t *testing.T) {
for ext, want := range map[string]string{
"doc": "docx", "xls": "xlsx", "ppt": "pptx",
} {
doc := Parse("旧档."+ext, "", []byte{0xD0, 0xCF})
if doc.Meta.Parser != "builtin:legacy-office" {
t.Fatalf("%s parser: %+v", ext, doc.Meta)
}
if len(doc.Blocks) != 0 || len(doc.Warnings) != 1 {
t.Fatalf("%s blocks/warnings: %+v %v", ext, doc.Blocks, doc.Warnings)
}
if !strings.Contains(doc.Warnings[0], want) {
t.Errorf("%s hint should mention %s: %v", ext, want, doc.Warnings[0])
}
}
}
// 注册表与分派行为一致:每个已注册格式的解析器 ID 与 Parse 分派结果一致,
// 且 builtin 解析器覆盖的扩展名都出现在注册表里。
func TestParsersDescriptionMatchesDispatch(t *testing.T) {
byFormat := map[string]string{}
for _, d := range ParsersDescription() {
if d.Source != core.SourceBuiltin {
continue
}
for _, f := range d.Formats {
byFormat[f] = d.ID
}
}
check := map[string]string{
"md": "builtin:md", "markdown": "builtin:md",
"txt": "builtin:txt", "text": "builtin:txt", "csv": "builtin:txt", "log": "builtin:txt",
"docx": "builtin:docx", "pdf": "builtin:pdf",
"xlsx": "builtin:xlsx", "xlsm": "builtin:xlsx",
"pptx": "builtin:pptx", "pptm": "builtin:pptx", "ppsx": "builtin:pptx",
"doc": "builtin:legacy-office", "xls": "builtin:legacy-office", "ppt": "builtin:legacy-office",
}
for ext, wantID := range check {
got, ok := byFormat[ext]
if !ok {
t.Errorf("format .%s missing from registry", ext)
continue
}
doc := Parse("a."+ext, "", []byte("x"))
if doc.Meta.Parser != wantID || got != wantID {
t.Errorf(".%s: registry=%s dispatch=%s want=%s", ext, got, doc.Meta.Parser, wantID)
}
}
}
// TestParseXlsxWideColumnSkipsCellNotSheets:稀疏样式单元格落在远端列(XFD 区,
// 列序 ≥1024)只跳过该格并告警——不误报单元格上限、不丢弃后续工作表。
func TestParseXlsxWideColumnSkipsCellNotSheets(t *testing.T) {
files := xlsxFixture()
// 第一表加一个列引用 XFD1(16384 列)的样式单元格;第二表(隐藏的"草稿")必须照常解析。
files["xl/worksheets/sheet1.xml"] = strings.Replace(files["xl/worksheets/sheet1.xml"],
"</row>\n<row r=\"4\"", "</row>\n<row r=\"3\"><c r=\"XFD3\" t=\"inlineStr\"><is><t>边角样式</t></is></c></row>\n<row r=\"4\"", 1)
doc := Parse("宽表.xlsx", "", zipBytes(t, files))
heads := []string{}
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading {
heads = append(heads, b.Text)
}
}
if strings.Join(heads, "|") != "报价|草稿(隐藏)" {
t.Fatalf("later sheets must survive a wide column: headings=%v warnings=%v", heads, doc.Warnings)
}
joined := strings.Join(doc.Warnings, ";")
if !strings.Contains(joined, "超过 1024 列") {
t.Fatalf("wide-column warning missing: %v", doc.Warnings)
}
if strings.Contains(joined, "单元格总数") {
t.Fatalf("cell-cap warning must not fire for a wide column: %v", doc.Warnings)
}
if doc.Meta.Confidence != 0.6 {
t.Fatalf("partial-content confidence expected 0.6: %v", doc.Meta.Confidence)
}
// 被跳过的远端列内容不得混进表格。
for _, b := range doc.Blocks {
if b.Type == core.BlockTable {
for _, row := range b.Rows {
for _, cell := range row {
if strings.Contains(cell, "边角样式") {
t.Fatalf("skipped wide-column cell leaked into table: %v", row)
}
}
}
}
}
}
// TestParseXlsxBadSheetXMLWarns:单张工作表 XML 损坏必须显式告警(其余表照常),
// 与提案「显式失败,不静默」一致。
func TestParseXlsxBadSheetXMLWarns(t *testing.T) {
files := xlsxFixture()
files["xl/worksheets/sheet1.xml"] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row`
doc := Parse("坏表.xlsx", "", zipBytes(t, files))
if len(doc.Warnings) == 0 {
t.Fatal("malformed sheet XML must warn")
}
joined := strings.Join(doc.Warnings, ";")
if !strings.Contains(joined, "工作表 报价") || !strings.Contains(joined, "XML 解码失败") {
t.Fatalf("warning should name the sheet and the failure: %v", doc.Warnings)
}
// 第二张表照常解析。
found := false
for _, b := range doc.Blocks {
if b.Type == core.BlockHeading && b.Text == "草稿(隐藏)" {
found = true
}
}
if !found {
t.Fatalf("healthy sheets must still parse: %+v", doc.Blocks)
}
if doc.Meta.Confidence != 0.6 {
t.Fatalf("partial parse confidence expected 0.6: %v", doc.Meta.Confidence)
}
}
+291
View File
@@ -0,0 +1,291 @@
package ingest
// pptx/pptm/ppsx(PresentationML)内置解析器。幻灯片顺序取 presentation.xml
// 的 sldIdLst;标题占位符(title/ctrTitle)产标题块,正文形状按段落输出
//(a:br 换行),组合形状经同一 token 走查天然递归;mc:AlternateContent 只取
// Choice 分支;graphicFrame 内的表格按 tr/tc 输出(gridSpan/hMerge 占位保列
// 对齐);备注页正文输出「备注:…」;页码/日期/页脚占位符跳过。图片与
// SmartArt 不解析。纯标准库。
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path"
"strconv"
"strings"
core "agent-system-core"
)
func parsePptx(content []byte) ([]core.DocBlock, []string) {
zr, err := openZip(content)
if err != nil {
return nil, []string{"pptx 解析失败:" + err.Error()}
}
slides, warns := pptxSlideOrder(zr)
if slides == nil && len(warns) > 0 {
return nil, warns
}
var blocks []core.DocBlock
for i, sp := range slides {
n := i + 1
data, err := zipPart(zr, sp)
if err != nil {
warns = append(warns, fmt.Sprintf("幻灯片 %d 读取失败:%v", n, err))
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: fmt.Sprintf("幻灯片 %d", n)})
continue
}
title, paras, tables := pptxSlideBlocks(data)
heading := fmt.Sprintf("幻灯片 %d", n)
if title != "" {
heading += ":" + title
}
blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: 2, Text: heading})
blocks = append(blocks, paras...)
blocks = append(blocks, tables...)
if note := pptxSlideNotes(zr, sp); note != "" {
blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: "备注:" + note})
}
}
if len(blocks) == 0 && len(warns) == 0 {
warns = append(warns, "pptx 无可解析的幻灯片内容")
}
return blocks, warns
}
// pptxSlideOrder 读 presentation.xml 的 sldIdLst → 幻灯片部件名(按顺序)。
func pptxSlideOrder(zr *zip.Reader) ([]string, []string) {
data, err := zipPart(zr, "ppt/presentation.xml")
if err != nil {
return nil, []string{"pptx 解析失败:" + err.Error()}
}
rels, err := parseRels(zr, "ppt/presentation.xml")
if err != nil {
return nil, []string{"pptx 解析失败:" + err.Error()}
}
dec := xml.NewDecoder(bytes.NewReader(data))
var out, warns []string
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return out, append(warns, "presentation.xml 解码失败:"+err.Error())
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "sldId" {
continue
}
rel, ok := rels[ridAttr(st)]
if !ok || !relIsType(rel, "/slide") {
warns = append(warns, "幻灯片关系缺失或类型不符")
continue
}
out = append(out, resolveRelTarget("ppt", rel.Target))
}
return out, warns
}
// pptxSlideBlocks 解析一张幻灯片:标题、正文段落、表格块。
// 表格块统一附在段落后(图形框与正文的精确穿插对检索无价值)。
func pptxSlideBlocks(data []byte) (title string, paras []core.DocBlock, tables []core.DocBlock) {
dec := xml.NewDecoder(bytes.NewReader(data))
var stack []string
has := func(name string) bool {
for _, s := range stack {
if s == name {
return true
}
}
return false
}
skip := 0 // mc:Fallback 子树深度
// 形状状态
spDepth := -1
phSkip := false
isTitle := false
var spParas []string
var para strings.Builder
paraOpen := false
// 文本收集目标
textInPara := false
textInCell := false
// 表格状态
var tblRows [][]string
var tblRow []string
var cellBuf strings.Builder
cellOpen := false
gridSpan := 1
cellHM := false
finishPara := func() {
if !paraOpen {
return
}
paraOpen = false
if s := strings.TrimRight(para.String(), "\n"); strings.TrimSpace(s) != "" {
spParas = append(spParas, strings.TrimSpace(s))
}
para.Reset()
}
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return "", paras, tables
}
switch t := tok.(type) {
case xml.StartElement:
if skip > 0 {
skip++
continue
}
local := t.Name.Local
switch local {
case "Fallback":
if has("AlternateContent") {
skip = 1
continue
}
case "sp":
spDepth = len(stack)
phSkip, isTitle, spParas = false, false, nil
case "ph":
if spDepth >= 0 && len(stack) > spDepth {
switch attrLocal(t, "type") {
case "title", "ctrTitle":
isTitle = true
case "dt", "ftr", "sldNum":
phSkip = true
}
}
case "p":
if spDepth >= 0 && has("txBody") && !has("tbl") {
finishPara()
paraOpen = true
para.Reset()
} else if cellOpen {
if cellBuf.Len() > 0 {
cellBuf.WriteByte('\n')
}
}
case "br":
if paraOpen {
para.WriteByte('\n')
}
case "t":
if has("fld") {
break // 字段(页码/日期)不取
}
textInPara = paraOpen
textInCell = cellOpen && !paraOpen
case "tc":
if has("tbl") {
cellBuf.Reset()
cellOpen = true
gridSpan, _ = strconv.Atoi(attrLocal(t, "gridSpan"))
if gridSpan < 1 {
gridSpan = 1
}
hm := attrLocal(t, "hMerge")
cellHM = hm == "1" || strings.EqualFold(hm, "true")
}
}
stack = append(stack, local)
case xml.CharData:
if textInPara {
para.Write(t)
} else if textInCell {
cellBuf.Write(t)
}
case xml.EndElement:
if skip > 0 {
skip--
continue
}
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
switch t.Name.Local {
case "t":
textInPara, textInCell = false, false
case "p":
finishPara()
case "sp":
if spDepth >= 0 && len(stack) == spDepth {
if !phSkip && len(spParas) > 0 {
if isTitle && title == "" {
title = strings.Join(spParas, " ")
} else {
for _, p := range spParas {
paras = append(paras, core.DocBlock{Type: core.BlockPara, Text: p})
}
}
}
spDepth = -1
phSkip, isTitle, spParas = false, false, nil
}
case "tc":
if cellOpen {
text := strings.TrimSpace(cellBuf.String())
if cellHM {
text = "" // 横向合并的续格:占位保列对齐
}
tblRow = append(tblRow, text)
for i := 1; i < gridSpan; i++ {
tblRow = append(tblRow, "") // 合并占位保列对齐
}
cellOpen = false
}
case "tr":
if len(tblRow) > 0 {
for len(tblRow) > 0 && tblRow[len(tblRow)-1] == "" {
tblRow = tblRow[:len(tblRow)-1]
}
if len(tblRow) > 0 {
tblRows = append(tblRows, tblRow)
}
tblRow = nil
}
case "tbl":
if len(tblRows) > 0 {
tables = append(tables, core.DocBlock{Type: core.BlockTable, Rows: tblRows})
}
tblRows = nil
}
}
}
finishPara()
return title, paras, tables
}
// pptxSlideNotes 读幻灯片的备注页正文;无备注返回空串。
func pptxSlideNotes(zr *zip.Reader, slidePart string) string {
rels, err := parseRels(zr, slidePart)
if err != nil {
return ""
}
for _, rel := range rels {
if !relIsType(rel, "/notesSlide") {
continue
}
data, err := zipPart(zr, resolveRelTarget(path.Dir(slidePart), rel.Target))
if err != nil {
return ""
}
_, paras, _ := pptxSlideBlocks(data)
var lines []string
for _, p := range paras {
lines = append(lines, p.Text)
}
return strings.Join(lines, "\n")
}
return ""
}
+425
View File
@@ -0,0 +1,425 @@
package ingest
// xlsx/xlsm(SpreadsheetML)内置解析器。工作表顺序取 workbook.xml 的 sheets
// 列表(target 经关系部件解析,支持相对/绝对路径);共享字符串含富文本 run
//(注音 rPh 无对应解码字段,天然排除);单元格覆盖 inlineStr/str/b/e/n,
// 公式取缓存值、无缓存回显公式;日期按数字格式(内置 ID 与自定义记号)转
// ISO,1900/1904 双日期系统;稀疏单元格按引用就位。纯标准库。
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"math"
"strconv"
"strings"
"time"
core "agent-system-core"
)
// 解析上限(包级变量,测试可覆盖)。
var (
xlsxMaxCells = 200000
xlsxMaxCols = 1024
)
type xlsxSheetRef struct {
Name string
State string
Path string
IsSheet bool
}
func parseXlsx(content []byte) ([]core.DocBlock, []string) {
zr, err := openZip(content)
if err != nil {
return nil, []string{"xlsx 解析失败:" + err.Error()}
}
sheets, date1904, warns := xlsxWorkbookSheets(zr)
if len(warns) > 0 && sheets == nil {
return nil, warns
}
shared := xlsxSharedStrings(zr)
dateStyles := xlsxDateStyles(zr)
var blocks []core.DocBlock
cells := 0
for _, sh := range sheets {
if !sh.IsSheet || sh.Path == "" {
continue // chartsheet 等非工作表部件
}
data, err := zipPart(zr, sh.Path)
if err != nil {
warns = append(warns, fmt.Sprintf("工作表 %s 读取失败:%v", sh.Name, err))
continue
}
table, used, trunc, wide, sheetWarn := xlsxSheetBlocks(data, shared, dateStyles, date1904, cells)
cells = used
if sheetWarn != "" {
warns = append(warns, fmt.Sprintf("工作表 %s:%s", sh.Name, sheetWarn))
}
if wide {
warns = append(warns, fmt.Sprintf("工作表 %s 含超过 %d 列的稀疏单元格,已跳过该列(其余内容不受影响)", sh.Name, xlsxMaxCols))
}
if table == nil {
continue // 空表(读取失败/坏 XML 已单独告警)
}
title := sh.Name
if sh.State == "hidden" || sh.State == "veryHidden" {
title += "(隐藏)"
}
blocks = append(blocks,
core.DocBlock{Type: core.BlockHeading, Level: 2, Text: title},
*table,
)
if trunc {
warns = append(warns, fmt.Sprintf("单元格总数超过 %d 上限,工作表 %s 之后未解析", xlsxMaxCells, sh.Name))
break
}
}
if len(blocks) == 0 && len(warns) == 0 {
warns = append(warns, "xlsx 无可解析的工作表内容")
}
return blocks, warns
}
// xlsxWorkbookSheets 读 workbook.xml:工作表顺序、隐藏状态、1904 日期系统。
func xlsxWorkbookSheets(zr *zip.Reader) (sheets []xlsxSheetRef, date1904 bool, warns []string) {
data, err := zipPart(zr, "xl/workbook.xml")
if err != nil {
return nil, false, []string{"xlsx 解析失败:" + err.Error()}
}
rels, err := parseRels(zr, "xl/workbook.xml")
if err != nil {
return nil, false, []string{"xlsx 解析失败:" + err.Error()}
}
dec := xml.NewDecoder(bytes.NewReader(data))
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return sheets, date1904, append(warns, "workbook.xml 解码失败:"+err.Error())
}
st, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch st.Name.Local {
case "workbookPr":
if v := attrLocal(st, "date1904"); v == "1" || strings.EqualFold(v, "true") {
date1904 = true
}
case "sheet":
ref := xlsxSheetRef{Name: attrLocal(st, "name"), State: attrLocal(st, "state")}
if rel, ok := rels[ridAttr(st)]; ok {
ref.Path = resolveRelTarget("xl", rel.Target)
ref.IsSheet = relIsType(rel, "/worksheet")
} else {
warns = append(warns, fmt.Sprintf("工作表 %s 的关系缺失", ref.Name))
}
sheets = append(sheets, ref)
}
}
return sheets, date1904, warns
}
// xlsxSharedStrings 读共享字符串表;富文本按 run 拼接,rPh 注音不进结果。
func xlsxSharedStrings(zr *zip.Reader) []string {
data, err := zipPart(zr, "xl/sharedStrings.xml")
if err != nil {
return nil
}
dec := xml.NewDecoder(bytes.NewReader(data))
var out []string
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return out
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "si" {
continue
}
var si struct {
T string `xml:"t"`
Runs []struct {
T string `xml:"t"`
} `xml:"r"`
}
if err := dec.DecodeElement(&si, &st); err != nil {
continue
}
if si.T != "" || len(si.Runs) == 0 {
out = append(out, si.T)
continue
}
parts := make([]string, 0, len(si.Runs))
for _, r := range si.Runs {
parts = append(parts, r.T)
}
out = append(out, strings.Join(parts, ""))
}
return out
}
// xlsxDateStyles 返回按日期呈现的样式索引集合(cellXfs 下标)。
func xlsxDateStyles(zr *zip.Reader) map[int]bool {
data, err := zipPart(zr, "xl/styles.xml")
if err != nil {
return nil
}
custom := map[int]string{}
date := map[int]bool{}
dec := xml.NewDecoder(bytes.NewReader(data))
inCellXfs := false
xfIdx := 0
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return date
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "numFmt":
id, _ := strconv.Atoi(attrLocal(t, "numFmtId"))
custom[id] = attrLocal(t, "formatCode")
case "cellXfs":
inCellXfs = true
xfIdx = 0
case "xf":
if !inCellXfs {
continue
}
id, _ := strconv.Atoi(attrLocal(t, "numFmtId"))
if xlsxFmtIsDate(id, custom) {
date[xfIdx] = true
}
xfIdx++
}
case xml.EndElement:
if t.Name.Local == "cellXfs" {
inCellXfs = false
}
}
}
return date
}
// xlsxFmtIsDate:自定义格式按记号判断;内置 ID 覆盖常见日期/时间区间。
func xlsxFmtIsDate(id int, custom map[int]string) bool {
if code, ok := custom[id]; ok {
return formatCodeIsDate(code)
}
switch {
case id >= 14 && id <= 22, id >= 27 && id <= 36,
id >= 45 && id <= 47, id >= 50 && id <= 58:
return true
}
return false
}
// formatCodeIsDate 剥掉引号字面量与方括号节([Red]、[DBNum1]、[$-409] 等)后,
// 剩余字母含 y/d/h/s/m 记号即按日期处理。
func formatCodeIsDate(code string) bool {
var b strings.Builder
inQuote, inBracket := false, false
runes := []rune(code)
for i := 0; i < len(runes); i++ {
r := runes[i]
switch {
case r == '"':
inQuote = !inQuote
case inQuote:
case r == '[':
inBracket = true
case r == ']':
inBracket = false
case inBracket:
case r == '\\':
i++ // 转义字符:连同下一字符跳过
default:
b.WriteRune(r)
}
}
return strings.ContainsAny(strings.ToLower(b.String()), "ydhsm")
}
type xlsxCellEl struct {
Ref string `xml:"r,attr"`
T string `xml:"t,attr"`
S string `xml:"s,attr"`
IS *struct {
T string `xml:"t"`
} `xml:"is"`
F string `xml:"f"`
V string `xml:"v"`
}
type xlsxRowEl struct {
Cells []xlsxCellEl `xml:"c"`
}
// xlsxSheetBlocks 解析单张工作表;返回表格块、累计单元格数、是否触达单元格
// 上限、是否跳过超宽列、以及坏 XML 的告警(显式失败,不静默)。无内容行
// 返回 nil 表。
func xlsxSheetBlocks(data []byte, shared []string, dateStyles map[int]bool, date1904 bool, cellsUsed int) (*core.DocBlock, int, bool, bool, string) {
dec := xml.NewDecoder(bytes.NewReader(data))
var rows [][]string
count := cellsUsed
truncated := false
wide := false
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, count, truncated, wide, "XML 解码失败:" + err.Error()
}
st, ok := tok.(xml.StartElement)
if !ok || st.Name.Local != "row" {
continue
}
var re xlsxRowEl
if err := dec.DecodeElement(&re, &st); err != nil {
continue
}
cells := map[int]string{}
maxCol := -1
for _, c := range re.Cells {
if count >= xlsxMaxCells {
truncated = true
break
}
count++
col := xlsxColIndex(c.Ref)
if col < 0 {
col = maxCol + 1 // 无引用:按出现顺序接续
}
if col >= xlsxMaxCols {
// 稀疏样式单元格常落在远端列(XFD 区):跳过该格并告警,
// 不中断本表其余内容,也不阻断后续工作表。
wide = true
continue
}
cells[col] = xlsxCellText(c, shared, dateStyles, date1904)
if col > maxCol {
maxCol = col
}
}
if len(cells) == 0 {
continue
}
full := make([]string, maxCol+1)
for i, v := range cells {
full[i] = v
}
for len(full) > 0 && strings.TrimSpace(full[len(full)-1]) == "" {
full = full[:len(full)-1]
}
if len(full) > 0 {
rows = append(rows, full)
}
}
if len(rows) == 0 {
return nil, count, truncated, wide, ""
}
return &core.DocBlock{Type: core.BlockTable, Rows: rows}, count, truncated, wide, ""
}
// xlsxCellText 把单元格转成展示文本。
func xlsxCellText(c xlsxCellEl, shared []string, dateStyles map[int]bool, date1904 bool) string {
text := ""
switch c.T {
case "s":
if idx, err := strconv.Atoi(c.V); err == nil && idx >= 0 && idx < len(shared) {
text = shared[idx]
}
case "inlineStr":
if c.IS != nil {
text = c.IS.T
}
case "b":
if c.V == "1" || strings.EqualFold(c.V, "true") {
text = "TRUE"
} else {
text = "FALSE"
}
case "e":
text = c.V
default: // n 或空:数字 / 公式缓存值
text = c.V
if sIdx, err := strconv.Atoi(c.S); err == nil && dateStyles[sIdx] && c.V != "" {
if f, err := strconv.ParseFloat(c.V, 64); err == nil {
text = xlsxSerialString(f, date1904)
}
}
}
if text == "" && c.F != "" {
return "=" + c.F // 无缓存值的公式:回显公式本身
}
return text
}
// xlsxSerialString Excel 日期序列值 → ISO 字符串;带小数部分时输出到秒。
func xlsxSerialString(serial float64, date1904 bool) string {
days := math.Floor(serial)
frac := serial - days
var t time.Time
switch {
case date1904:
t = time.Date(1904, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
case days >= 61:
t = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
default: // 1–60:兼容 Excel 的 1900 闰年 bug 区段
t = time.Date(1899, 12, 31, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days))
}
if frac > 0 {
return t.Add(time.Duration(frac * 86400 * float64(time.Second))).Round(time.Second).Format("2006-01-02 15:04:05")
}
return t.Format("2006-01-02")
}
// xlsxColIndex "BC12" → 0 起列号;无引用或非法返回 -1。
func xlsxColIndex(ref string) int {
if ref == "" {
return -1
}
i := 0
for i < len(ref) {
ch := ref[i]
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') {
i++
continue
}
break
}
if i == 0 || i == len(ref) {
return -1
}
for _, ch := range ref[i:] {
if ch < '0' || ch > '9' {
return -1
}
}
n := 0
for _, ch := range ref[:i] {
c := ch
if c >= 'a' && c <= 'z' {
c = c - 'a' + 'A'
}
n = n*26 + int(c-'A') + 1
}
return n - 1
}
+56 -1
View File
@@ -24,20 +24,74 @@ type MatchResult struct {
ID string
Slots map[string]string
Missing []SlotQuestion
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
Commands []Command
}
// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入,
// Router/runmgr 等消费方只依赖本包表面。
type (
Command = agentintent.Command
ActionRef = agentintent.ActionRef
)
const (
CommandStart = agentintent.CommandStart
ActionAuto = agentintent.ActionAuto
ActionCommand = agentintent.ActionCommand
ActionWorkflow = agentintent.ActionWorkflow
ActionBlueprint = agentintent.ActionBlueprint
ActionHandler = agentintent.ActionHandler
)
type Gate struct {
mu sync.RWMutex
reg *agentintent.Registry
rec *agentintent.FunnelRecognizer
specs int
specsByID map[string]agentintent.IntentSpec
// 慢通道(I2):nil = 不启用(默认,行为与 I1 一致)。
sem agentintent.SemanticScorer
semThresh float64
gen agentintent.CommandGenerator
}
func New() *Gate {
return &Gate{rec: agentintent.NewRecognizer(agentintent.Options{}, nil, nil)}
}
// SetSemantic 接线语义打分器(慢通道中位)。识别器随之重建;
// 传 nil 关闭语义层。阈值 ≤0 用库默认(0.8)。
func (g *Gate) SetSemantic(s agentintent.SemanticScorer, threshold float64) {
g.mu.Lock()
defer g.mu.Unlock()
g.sem, g.semThresh = s, threshold
g.rebuildRecognizerLocked()
}
// SetGenerator 接线模型命令生成器(慢通道末位)。识别器随之重建;
// 传 nil 关闭生成层。
func (g *Gate) SetGenerator(gen agentintent.CommandGenerator) {
g.mu.Lock()
defer g.mu.Unlock()
g.gen = gen
g.rebuildRecognizerLocked()
}
// rebuildRecognizerLocked 按当前慢通道配置重建识别器(调用方持写锁)。
func (g *Gate) rebuildRecognizerLocked() {
if g.sem == nil && g.gen == nil {
g.rec = agentintent.NewRecognizer(agentintent.Options{}, nil, nil)
return
}
g.rec = agentintent.NewRecognizer(agentintent.Options{
Semantic: g.sem,
SemanticThreshold: g.semThresh,
Generator: g.gen,
}, nil, nil)
}
// Rebuild 用插件意图 overlay 重建注册表;失败保留上一快照(fail-closed)并返回错误。
func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
if len(overlay) == 0 {
@@ -54,7 +108,7 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
}
specs = append(specs, s)
}
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3})
b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3, NFKC: true})
if err := b.Register(specs...); err != nil {
return fmt.Errorf("intentgate: register: %w", err)
}
@@ -95,6 +149,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
switch res.Branch {
case agentintent.BranchMatched:
out.Branch = "matched"
out.Commands = res.Commands
case agentintent.BranchAmbiguous:
out.Branch = "ambiguous"
}
+85
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"testing"
agentintent "agent-intent/intent"
)
func raw(t *testing.T, v any) json.RawMessage {
@@ -91,3 +93,86 @@ func TestRecognizeKeepsSlots(t *testing.T) {
t.Fatalf("Match wrapper = %s %s", branch, id)
}
}
// ---- I2 慢通道接线(默认关;SetSemantic/SetGenerator 开启)----
// stubScorer 固定返回脚本分(按 examples[0] 查表)。
type stubScorer struct{ scores map[string]float64 }
func (s stubScorer) Score(_ context.Context, _ string, examples []string) (float64, error) {
if len(examples) == 0 {
return 0, nil
}
return s.scores[examples[0]], nil
}
// stubGenerator 固定返回脚本命令。
type stubGenerator struct{ cmds []Command }
func (g stubGenerator) Generate(_ context.Context, _ string, _ []agentintent.CatalogSpec) ([]agentintent.Command, error) {
return g.cmds, nil
}
func ambiguousOverlay(t *testing.T) []json.RawMessage {
t.Helper()
mk := func(id string, ex []string) json.RawMessage {
return raw(t, map[string]any{
"id": id, "domain": "demo", "version": 1, "priority": 5,
"matcher": map[string]any{"includes_any": []string{"模糊词"}},
"examples": ex,
})
}
return []json.RawMessage{mk("demo.alpha", []string{"查运行进度"}), mk("demo.beta", []string{"闲聊问候"})}
}
// 语义层:歧义被过阈打分裁决为 matched(默认关时不启用)。
func TestGateSemanticResolvesAmbiguous(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), ambiguousOverlay(t)); err != nil {
t.Fatal(err)
}
// 默认(未接线):规则层歧义原样透出。
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
t.Fatalf("默认关:branch = %s", branch)
}
g.SetSemantic(stubScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}, 0)
branch, top := g.Match(context.Background(), "模糊词")
if branch != "matched" || top != "demo.alpha" {
t.Fatalf("语义裁决:%s/%s", branch, top)
}
// 关闭后回到歧义。
g.SetSemantic(nil, 0)
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
t.Fatalf("关闭语义层:branch = %s", branch)
}
}
// 生成层:fallback 时产出合法 start 命令 → matched + Commands。
func TestGateGeneratorProducesCommands(t *testing.T) {
g := New()
overlay := []json.RawMessage{raw(t, map[string]any{
"id": "report.generate", "domain": "report", "version": 1,
"description": "生成报告",
"matcher": map[string]any{"includes_any": []string{"生成报告"}},
"handler": "reportHandler",
})}
if err := g.Rebuild(context.Background(), overlay); err != nil {
t.Fatal(err)
}
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
}}})
res := g.Recognize(context.Background(), "完全不相关的一句话")
if res.Branch != "matched" || res.ID != "report.generate" {
t.Fatalf("生成器命中:%+v", res)
}
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
t.Fatalf("commands: %+v", res.Commands)
}
// 关闭后回到 fallback。
g.SetGenerator(nil)
if branch, _ := g.Match(context.Background(), "完全不相关的一句话"); branch != "fallback" {
t.Fatalf("关闭生成层:branch = %s", branch)
}
}
+129
View File
@@ -0,0 +1,129 @@
package intentsem
import (
"context"
"encoding/json"
"fmt"
"strings"
agentintent "agent-intent/intent"
agentllm "agent-native/runtime/llm"
)
// LightGenerator 用轻量模型实现 agentintent.CommandGenerator
// (Rasa 式 LLM command generator:只把相关流程交给生成器)。
// 提示词给出能力目录(spec id/描述/槽位键),要求模型输出严格的
// 命令 JSON 数组;解析失败或模型不可达都返回错误——库侧约定生成器
// 出错时识别保持 fallback(fail-closed)。
type LightGenerator struct {
// Stream 提供方边界(注入点;测试可脚本化)。
Stream agentllm.StreamFunc
// Model 是轻量档模型名。
Model string
// APIBase/APIKey 走 StreamOptions(客户端可按次覆盖)。
APIBase string
APIKey string
// MaxTokens 上限;0 = 1024。
MaxTokens int
}
// 生成输出契约:action 与 agentintent.ActionRef 同形(kind/id)。
type generatedCommand struct {
Kind string `json:"kind"`
Action generatedAction `json:"action,omitempty"`
Slots map[string]string `json:"slots,omitempty"`
}
type generatedAction struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
// Generate 基于输入与能力目录产出命令列表。目录为空时不调用模型。
func (g LightGenerator) Generate(ctx context.Context, input string, catalog []agentintent.CatalogSpec) ([]agentintent.Command, error) {
if g.Stream == nil || len(catalog) == 0 || strings.TrimSpace(input) == "" {
return nil, nil
}
maxTok := g.MaxTokens
if maxTok <= 0 {
maxTok = 1024
}
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: generatorSystemPrompt(catalog)},
{Role: agentllm.RoleUser, Content: input},
}
ch := g.Stream(ctx, g.Model, msgs, nil, agentllm.StreamOptions{
MaxTokens: maxTok, APIBase: g.APIBase, APIKey: g.APIKey,
})
var sb strings.Builder
for ev := range ch {
switch ev.Kind {
case agentllm.KindTextDelta:
sb.WriteString(ev.Text)
case agentllm.KindError:
if ev.Err != nil {
return nil, fmt.Errorf("intentsem: stream: %w", ev.Err)
}
return nil, fmt.Errorf("intentsem: stream: %s", ev.ErrorMessage)
}
}
return parseCommands(sb.String())
}
// parseCommands 从模型输出解析命令数组:剥代码围栏,取首个 [ 到末个 ],
// 严格 JSON。任何形状不符都报错(由库侧 fail-closed 到 fallback)。
func parseCommands(raw string) ([]agentintent.Command, error) {
text := strings.TrimSpace(raw)
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
lo, hi := strings.Index(text, "["), strings.LastIndex(text, "]")
if lo < 0 || hi <= lo {
return nil, fmt.Errorf("intentsem: 输出不含命令数组")
}
var gen []generatedCommand
if err := json.Unmarshal([]byte(text[lo:hi+1]), &gen); err != nil {
return nil, fmt.Errorf("intentsem: 命令 JSON: %w", err)
}
out := make([]agentintent.Command, 0, len(gen))
for _, c := range gen {
out = append(out, agentintent.Command{
Kind: agentintent.CommandKind(c.Kind),
Action: agentintent.ActionRef{Kind: c.Action.Kind, ID: c.Action.ID},
Slots: c.Slots,
})
}
return out, nil
}
// generatorSystemPrompt 渲染能力目录。只暴露 id/描述/槽位键——
// 不暴露模式与示例(避免提示词注入借道示例文本进入慢通道)。
func generatorSystemPrompt(catalog []agentintent.CatalogSpec) string {
var sb strings.Builder
sb.WriteString("你是意图命令生成器。根据用户输入,从下面的能力目录选择并输出命令。\n")
sb.WriteString("只输出一个 JSON 数组,不要输出任何其他文字。数组元素形如:\n")
sb.WriteString(`[{"kind":"start","action":{"kind":"workflow","id":"<目录中的id>"},"slots":{"<槽位键>":"<值>"}}]` + "\n")
sb.WriteString("规则:kind 取 start/set_slot/cancel/clarify/chitchat/handoff 之一;")
sb.WriteString("start 必须带 action,action.kind 取 auto/command/workflow/blueprint/handler;")
sb.WriteString("action.id 与 slots 的键必须来自目录;用户闲聊用 chitchat(不带 action);")
sb.WriteString("无法判断时输出空数组 []。\n能力目录:\n")
for _, spec := range catalog {
sb.WriteString("- id=" + spec.ID)
if spec.Description != "" {
sb.WriteString(" 描述=" + spec.Description)
}
if spec.Handler != "" {
sb.WriteString(" handler=" + spec.Handler)
}
if len(spec.Slots) > 0 {
keys := make([]string, 0, len(spec.Slots))
for _, sl := range spec.Slots {
keys = append(keys, sl.Key)
}
sb.WriteString(" 槽位=[" + strings.Join(keys, ",") + "]")
}
sb.WriteString("\n")
}
return sb.String()
}
+149
View File
@@ -0,0 +1,149 @@
package intentsem
import (
"context"
"errors"
"strings"
"testing"
agentintent "agent-intent/intent"
agentllm "agent-native/runtime/llm"
)
// fakeEmbedder 返回脚本化向量:texts[i] → vecs[i](循环取模)。
type fakeEmbedder struct {
vecs [][]float32
err error
}
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
if f.err != nil {
return nil, f.err
}
out := make([][]float32, len(texts))
for i := range texts {
out[i] = f.vecs[i%len(f.vecs)]
}
return out, nil
}
func TestEmbeddingScorerMaxCosine(t *testing.T) {
// input 与 example2 同向(相似度 1),与 example1 正交(0)。
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{
{1, 0}, {0, 1}, {2, 0},
}}}
got, err := s.Score(context.Background(), "输入", []string{"例一", "例二"})
if err != nil {
t.Fatal(err)
}
if got != 1 {
t.Fatalf("期望最大余弦 1,得到 %v", got)
}
}
func TestEmbeddingScorerErrorPropagates(t *testing.T) {
s := EmbeddingScorer{Provider: fakeEmbedder{err: errors.New("boom")}}
if _, err := s.Score(context.Background(), "x", []string{"y"}); err == nil {
t.Fatal("期望错误上抛(库侧保持 Ambiguous)")
}
}
func TestEmbeddingScorerEmptyExamples(t *testing.T) {
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{{1}}}}
got, err := s.Score(context.Background(), "x", nil)
if err != nil || got != 0 {
t.Fatalf("空示例应得 0 分无错误,得到 %v / %v", got, err)
}
}
// scriptedStream 回放固定文本增量后关闭通道。
func scriptedStream(text string, err error) agentllm.StreamFunc {
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 3)
if err != nil {
ch <- agentllm.Event{Kind: agentllm.KindError, Err: err}
} else {
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
}
close(ch)
return ch
}
}
func catalogFixture() []agentintent.CatalogSpec {
return []agentintent.CatalogSpec{
{ID: "deploy.run", Description: "部署应用", Slots: []agentintent.CatalogSlot{{Key: "env", Required: true}}},
}
}
func TestLightGeneratorParsesCommands(t *testing.T) {
g := LightGenerator{
Stream: scriptedStream(" ```json\n[{\"kind\":\"start\",\"action\":{\"kind\":\"workflow\",\"id\":\"deploy.run\"},\"slots\":{\"env\":\"prod\"}}]\n``` ", nil),
Model: "lite",
}
cmds, err := g.Generate(context.Background(), "把应用部署到生产", catalogFixture())
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandStart {
t.Fatalf("期望 1 条 start 命令,得到 %+v", cmds)
}
if cmds[0].Action.Kind != "workflow" || cmds[0].Action.ID != "deploy.run" {
t.Fatalf("action 不符:%+v", cmds[0].Action)
}
if cmds[0].Slots["env"] != "prod" {
t.Fatalf("槽位不符:%+v", cmds[0].Slots)
}
}
func TestLightGeneratorEmptyCatalogNoCall(t *testing.T) {
called := false
g := LightGenerator{Stream: func(_ context.Context, _ string, _ []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
called = true
ch := make(chan agentllm.Event)
close(ch)
return ch
}}
cmds, err := g.Generate(context.Background(), "你好", nil)
if err != nil || cmds != nil || called {
t.Fatalf("空目录不应调用模型:cmds=%v err=%v called=%v", cmds, err, called)
}
}
func TestLightGeneratorStreamError(t *testing.T) {
g := LightGenerator{Stream: scriptedStream("", errors.New("dial refused"))}
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil ||
!strings.Contains(err.Error(), "dial refused") {
t.Fatalf("期望流错误上抛,得到 %v", err)
}
}
func TestLightGeneratorBadJSON(t *testing.T) {
g := LightGenerator{Stream: scriptedStream("我无法判断", nil)}
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil {
t.Fatal("期望解析失败返回错误(库侧 fail-closed 回 fallback)")
}
}
func TestParseCommandsFencedAndChatty(t *testing.T) {
raw := "好的,命令如下:\n```json\n[{\"kind\":\"chitchat\"}]\n```\n以上。"
cmds, err := parseCommands(raw)
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandChitchat {
t.Fatalf("期望 chitchat,得到 %+v", cmds)
}
}
func TestGeneratorPromptContainsCatalogOnly(t *testing.T) {
p := generatorSystemPrompt(catalogFixture())
for _, want := range []string{"deploy.run", "部署应用", "env"} {
if !strings.Contains(p, want) {
t.Fatalf("提示词缺少 %q", want)
}
}
if strings.Contains(p, "正例") || strings.Contains(p, "examples") {
t.Fatal("提示词不应包含示例文本(注入面)")
}
}
+62
View File
@@ -0,0 +1,62 @@
// Package intentsem 慢通道实现(I2 增量三):把 agent-intent 的
// SemanticScorer / CommandGenerator 端口接到宿主已有的向量与轻量模型
// 能力上。两个实现都是可插拔的——装配层默认不接线(设置开关关),
// 此时识别行为与 I1 完全一致。
package intentsem
import (
"context"
"math"
core "agent-system-core"
)
// EmbeddingScorer 用语义向量实现 agentintent.SemanticScorer:
// 对 input 与每个 example 取向量,返回最大余弦相似度。
// 任何错误都原样上抛——库侧约定 scorer 出错时识别保持 Ambiguous,
// 不会使 Recognize 失败(fail-open 到规则层结论)。
type EmbeddingScorer struct {
Provider core.EmbeddingProvider
}
// Score 返回 input 与 examples 的最高余弦相似度,取值 [0,1]。
func (s EmbeddingScorer) Score(ctx context.Context, input string, examples []string) (float64, error) {
if s.Provider == nil || len(examples) == 0 {
return 0, nil
}
texts := make([]string, 0, len(examples)+1)
texts = append(texts, input)
texts = append(texts, examples...)
vecs, err := s.Provider.Embed(ctx, texts)
if err != nil {
return 0, err
}
if len(vecs) != len(texts) || len(vecs[0]) == 0 {
return 0, nil
}
best := 0.0
for _, v := range vecs[1:] {
if sim := cosine(vecs[0], v); sim > best {
best = sim
}
}
return best, nil
}
// cosine 计算两个等长向量的余弦相似度;任一零向量或不等长返回 0。
func cosine(a, b []float32) float64 {
if len(a) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
fa, fb := float64(a[i]), float64(b[i])
dot += fa * fb
na += fa * fa
nb += fb * fb
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
+41
View File
@@ -0,0 +1,41 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
core "agent-system-core"
)
// agentBannedKeys 是插件代理声明禁带的键:钩子、MCP 与权限模式归宿主与
// 基座,插件画像只能声明身份与工具面(N1 WP1 验收条款)。
var agentBannedKeys = map[string]bool{
"hooks": true,
"mcp": true,
"permissionMode": true,
}
// parseAgentAsset 解析 agents/ 资产:禁带键定位到字段、未知键拒绝、
// id 必填。任一违规由调用方整批拒绝该插件(fail-closed)。
func parseAgentAsset(raw []byte) (core.PluginAgentDecl, error) {
var probe map[string]json.RawMessage
if err := json.Unmarshal(raw, &probe); err != nil {
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
}
for key := range probe {
if agentBannedKeys[key] {
return core.PluginAgentDecl{}, fmt.Errorf("插件代理不得声明 %s", key)
}
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var item core.PluginAgentDecl
if err := dec.Decode(&item); err != nil {
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
}
if item.ID == "" {
return core.PluginAgentDecl{}, fmt.Errorf("agent missing id")
}
return item, nil
}
+72
View File
@@ -0,0 +1,72 @@
package plugins
import (
"os"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
// writeAgentPlugin 写一个带 agents/ 资产的插件目录。
func writeAgentPlugin(t *testing.T, dir, id, agentJSON string) {
t.Helper()
root := filepath.Join(dir, id)
os.MkdirAll(filepath.Join(root, "agents"), 0o755)
write := func(rel, content string) {
if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write("plugin.json", `{"id":"`+id+`","name":"代理插件","version":"0.1.0","apiVersion":"`+core.PluginAPIVersion+`",
"provides":{"agents":["reviewer"]}}`)
write("agents/reviewer.json", agentJSON)
}
func TestAgentAssetLoads(t *testing.T) {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "agentplug", `{"id":"rdm-reviewer","title":"章节评审","instructions":"逐条核对来源","tools":["read_file","grep"]}`)
if err := h.LoadDir()["agentplug"]; err != nil {
t.Fatal(err)
}
for _, ld := range h.List() {
if ld.Manifest.ID != "agentplug" {
continue
}
if len(ld.Agents) != 1 || ld.Agents[0].ID != "rdm-reviewer" || len(ld.Agents[0].Tools) != 2 {
t.Fatalf("agents = %+v", ld.Agents)
}
return
}
t.Fatal("plugin not loaded")
}
// TestAgentAssetBansHostOnlyKeys:插件代理声明 hooks/mcp/permissionMode 时
// 整批拒绝且错误定位到字段(N1 WP1 验收条款)。
func TestAgentAssetBansHostOnlyKeys(t *testing.T) {
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "badplug", `{"id":"x","`+key+`":{}}`)
err := h.LoadDir()["badplug"]
if err == nil {
t.Fatalf("%s accepted", key)
}
if !strings.Contains(err.Error(), "不得声明 "+key) {
t.Fatalf("%s error 未定位到字段: %v", key, err)
}
}
}
func TestAgentAssetRejectsUnknownKeysAndMissingID(t *testing.T) {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "unknownkey", `{"id":"x","bogus":1}`)
if err := h.LoadDir()["unknownkey"]; err == nil {
t.Fatal("unknown key accepted")
}
h2, _, dir2 := newHost(t)
writeAgentPlugin(t, dir2, "noid", `{"title":"无 id"}`)
if err := h2.LoadDir()["noid"]; err == nil {
t.Fatal("missing id accepted")
}
}
+11 -2
View File
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"agent-system-backend/internal/skills"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
@@ -59,6 +60,10 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
if err != nil {
return fmt.Errorf("plugins: %s: skill %s: %w", m.ID, name, err)
}
// 技能按开放规范校验(N2 WP4):不合规整批拒载,字段定位到键。
if err := skills.ValidateForPlugin(m.ID, name, body); err != nil {
return err
}
if ld.Skills == nil {
ld.Skills = map[string]string{}
}
@@ -98,8 +103,12 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
ld.Parsers = append(ld.Parsers, item)
}
for _, name := range m.Provides.Agents {
var item core.PluginAgentDecl
if err := readAsset(dir, "agents", name, &item); err != nil {
raw, err := readPluginFile(dir, filepath.Join("agents", name+".json"))
if err != nil {
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
}
item, err := parseAgentAsset(raw)
if err != nil {
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
}
ld.Agents = append(ld.Agents, item)
+32 -1
View File
@@ -3,6 +3,7 @@ package plugins
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -33,7 +34,7 @@ func TestV2AssetsAndNetwork(t *testing.T) {
"provides":{"tools":["echo"],"skills":["note"],"mcpServers":["local"],"knowledge":["regs"],"parsers":["pdf"],"agents":["writer"],"channels":["feishu"],"pages":["home"]}
}`)
write("tools/echo.json", `{"name":"v2_echo","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://api.example.com/x"}}`)
write("skills/note.md", "# note\n")
write("skills/note.md", "---\nname: note\ndescription: 笔记技能\n---\n\n# note\n")
write("mcp/local.json", `{"name":"local","command":"echo"}`)
write("knowledge/regs.json", `{"id":"regs","title":"法规"}`)
write("parsers/pdf.json", `{"id":"pdf","title":"PDF","mime":"application/pdf"}`)
@@ -95,3 +96,33 @@ func TestSidecarTouch(t *testing.T) {
}
t.Fatal("sidecar did not run")
}
// N2 WP4:不合规插件技能整批拒载,错误带插件上下文与字段定位。
func TestV2InvalidSkillRejected(t *testing.T) {
host, _, dir := newHost(t)
root := filepath.Join(dir, "badskill")
if err := os.MkdirAll(filepath.Join(root, "skills"), 0o755); err != nil {
t.Fatal(err)
}
write := func(rel, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("plugin.json", `{
"id":"badskill","name":"Bad","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`",
"backend":{"schemaVersion":1},
"provides":{"skills":["note"]}
}`)
// 缺 name(开放规范必填)。
write("skills/note.md", "---\ndescription: 没有名字\n---\n正文\n")
err := host.LoadDir()["badskill"]
if err == nil {
t.Fatal("不合规技能必须拒载")
}
if !strings.Contains(err.Error(), "plugins: badskill: skill note:") ||
!strings.Contains(err.Error(), "name") {
t.Fatalf("错误须带插件上下文与字段定位:%v", err)
}
}
@@ -11,8 +11,8 @@ 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. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
+16
View File
@@ -0,0 +1,16 @@
---
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/运行),验证通过再继续下一处。
- **坚持到完成**:任务没做完不要停。收尾前检查最后一段:如果还是计划、承诺或"接下来我将",就先把承诺的事做掉。只有用户输入才能解决的阻塞才允许停下来问。
- **结论回到材料**:写进交付物的数字、百分比、日期、人名必须能在本轮材料(附件/工具输出/会议原文)里找到出处;找不到的改成不写,或明确标注"推测"。材料里的日期基准与你理解的不一致时,以材料为准或向用户确认。
- **终稿如实**:最后的回复先给结论,再列交付物与要点;没做完、没验证的部分如实说明,不用模糊措辞掩盖。
+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
}
+32
View File
@@ -124,6 +124,38 @@ func firstRef(docs []core.KnowledgeDoc) string {
return ""
}
// RetrieveShared EXT-8 余项:本人两路如常 + 追加共享两路(向量 Shared + 全文
// Shared,allow=适配器判定),RRF 融合后裁剪 topK。embedder 不可用时共享仅全文
// (与切片 2 行为一致)。
func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
own, err := p.Retrieve(ctx, owner, q)
if err != nil {
return nil, err
}
shared, err := p.st.SearchKnowledgeShared(string(owner), q, allow)
if err != nil {
return nil, err
}
// 向量共享路:embedder 可用时并入。
if p.embedder != nil && p.embeddingRoute(ctx).OK() {
if qvecs, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(qvecs) > 0 && len(qvecs[0]) > 0 {
topK := q.TopK
if topK <= 0 {
topK = 5
}
vs, e2 := p.st.SearchKnowledgeVectorsShared(ctx, string(owner), qvecs[0], topK, q, allow)
if e2 == nil {
shared = rrfMerge(vs, shared)
}
}
}
merged := rrfMerge(own, shared)
if limit := q.TopK; limit > 0 && len(merged) > limit {
merged = merged[:limit]
}
return merged, nil
}
// Retrieve 统一检索(core.Retriever)。
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
topK := q.TopK
+53
View File
@@ -0,0 +1,53 @@
package rag
import (
"context"
"path/filepath"
"testing"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// EXT-8 余项红/绿:向量通道共享——他人 owner 的向量命中经 allow 放行可检。
// 用 hash 档 embedder(确定性向量),owner=alice 建项目知识并嵌入;bob 经
// RetrieveShared 放行后命中(含 vector Kind),拒绝时零命中。
func TestRetrieveSharedVectorChannel(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
p := NewPipeline(st, set, nil, nil)
p.SetEmbedder(SwitchingEmbedder{Provider: func() string { return "hash" }})
docs := []core.KnowledgeDoc{{Owner: "alice", ProjectID: "prj-s", Source: "plugin:rdm",
Ref: "kb-v", Chunk: "向量共享独占词"}}
// 只走管线写入(分块行+向量一体;不再手工 AddKnowledge 避免主键冲突)。
if err := indexForTest(t, st, p, docs); err != nil {
t.Fatal(err)
}
allow := func(docOwner, projectID core.ID) bool {
return docOwner == "alice" && projectID == "prj-s"
}
hits, err := p.RetrieveShared(context.Background(), "bob", core.RetrievalQuery{Text: "向量共享独占词", TopK: 5}, allow)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("authorized shared vector hit must be retrievable(向量通道共享)")
}
deny := func(docOwner, projectID core.ID) bool { return false }
if hits2, _ := p.RetrieveShared(context.Background(), "bob", core.RetrievalQuery{Text: "向量共享独占词", TopK: 5}, deny); len(hits2) != 0 {
t.Fatalf("denied shared search = %d hits, want 0", len(hits2))
}
}
// indexForTest:把 docs 直接嵌入并落向量(复用管线的公开写入路径)。
func indexForTest(t *testing.T, st *store.Store, p *Pipeline, docs []core.KnowledgeDoc) error {
t.Helper()
return p.IndexChunks(context.Background(), "alice", docs)
}
+114
View File
@@ -0,0 +1,114 @@
// Package route 是 Backend 的唯一意图 Router(I1 WP3)。
// 它把识别器产出的命令列表统一裁决为路由决策,收编原散落在
// server(chat 内联分支)与 runmgr(HITL 自由输入转轨)的两套逻辑。
//
// 优先级明文(提案 §6 要求 I1 写明):
// - auto/handler:命令总线 > 工作流 > 普通回合(与现网行为一致)。
// - 显式 command/workflow/blueprint:按声明执行;声明目标在当前环境
// 不可解析(命令不存在、工作流未注册、小队未启用)时不静默错配——
// 回落普通回合并把原因写进 Reason。
// - PromptTrigger(技能触发)是提示词装配、不参与路由决胜;
// 意图产出 Start/Clarify 类命令时路由优先。
//
// 当前版本的已知边界(如实记录,不静默):
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
// 本版本调用方按普通回合处理并保留 Decision 供审计。
// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机
// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为
// fallback,Start 只来自 matched)。
package route
import (
"agent-system-backend/internal/intentgate"
core "agent-system-core"
)
// Kind 是路由决策类型。
type Kind string
const (
KindCommand Kind = "command"
KindWorkflow Kind = "workflow"
KindSquad Kind = "squad"
KindTurn Kind = "turn"
)
// Decision 是一次路由裁决。
type Decision struct {
Kind Kind
CommandID string
Workflow core.WorkflowDef
Blueprint string
Slots map[string]string
Missing []intentgate.SlotQuestion
Reason string // 回落 turn 的原因(显式声明不可解析时)
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
}
// Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。
type Env struct {
HasCommand func(id string) bool
WorkflowFor func(intentID string) (core.WorkflowDef, bool)
SquadReady bool
}
// Decide 裁决一次识别结果。规则:
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。
// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流;
// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。
func Decide(res intentgate.MatchResult, env Env) Decision {
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing}
if res.Branch != "matched" {
return d
}
var start *intentgate.Command
for i := range res.Commands {
if res.Commands[i].Kind == intentgate.CommandStart {
start = &res.Commands[i]
break
}
}
action := intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: res.ID}
if start != nil {
d.StartFound = true
action = start.Action
if len(start.Slots) > 0 {
d.Slots = start.Slots
}
}
switch action.Kind {
case intentgate.ActionCommand:
if env.HasCommand != nil && env.HasCommand(action.ID) {
d.Kind, d.CommandID = KindCommand, action.ID
return d
}
d.Reason = "声明的命令不存在:" + action.ID
case intentgate.ActionWorkflow:
if env.WorkflowFor != nil {
if def, ok := env.WorkflowFor(action.ID); ok {
d.Kind, d.Workflow = KindWorkflow, def
return d
}
}
d.Reason = "声明的工作流未注册:" + action.ID
case intentgate.ActionBlueprint:
if env.SquadReady {
d.Kind, d.Blueprint = KindSquad, action.ID
return d
}
d.Reason = "声明的小队未启用:" + action.ID
default: // auto / handler
if env.HasCommand != nil && env.HasCommand(action.ID) {
d.Kind, d.CommandID = KindCommand, action.ID
return d
}
if env.WorkflowFor != nil {
if def, ok := env.WorkflowFor(action.ID); ok {
d.Kind, d.Workflow = KindWorkflow, def
return d
}
}
}
return d
}
+83
View File
@@ -0,0 +1,83 @@
package route
import (
"testing"
"agent-system-backend/internal/intentgate"
core "agent-system-core"
)
func startCmd(action intentgate.ActionRef) intentgate.Command {
return intentgate.Command{Kind: intentgate.CommandStart, Action: action}
}
func TestDecideAutoPrefersCommandThenWorkflow(t *testing.T) {
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
Commands: []intentgate.Command{startCmd(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.x"})}}
d := Decide(in, Env{HasCommand: func(string) bool { return true }})
if d.Kind != KindCommand || d.CommandID != "demo.x" {
t.Fatalf("auto command = %+v", d)
}
d = Decide(in, Env{
HasCommand: func(string) bool { return false },
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
})
if d.Kind != KindWorkflow || d.Workflow.ID != "wf" {
t.Fatalf("auto workflow = %+v", d)
}
d = Decide(in, Env{})
if d.Kind != KindTurn || !d.StartFound {
t.Fatalf("auto turn = %+v", d)
}
}
func TestDecideExplicitKinds(t *testing.T) {
mk := func(kind, id string) intentgate.MatchResult {
return intentgate.MatchResult{Branch: "matched", ID: "demo.x",
Commands: []intentgate.Command{startCmd(intentgate.ActionRef{Kind: kind, ID: id})}}
}
if d := Decide(mk(intentgate.ActionCommand, "cmd-1"), Env{HasCommand: func(id string) bool { return id == "cmd-1" }}); d.Kind != KindCommand || d.CommandID != "cmd-1" {
t.Fatalf("explicit command = %+v", d)
}
d := Decide(mk(intentgate.ActionCommand, "nope"), Env{
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
})
if d.Kind != KindTurn || d.Reason == "" {
t.Fatalf("unresolvable command = %+v", d)
}
if d := Decide(mk(intentgate.ActionWorkflow, "wf-1"), Env{
WorkflowFor: func(id string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: id}, true },
}); d.Kind != KindWorkflow || d.Workflow.ID != "wf-1" {
t.Fatalf("explicit workflow = %+v", d)
}
if d := Decide(mk(intentgate.ActionBlueprint, "bp-1"), Env{SquadReady: true}); d.Kind != KindSquad || d.Blueprint != "bp-1" {
t.Fatalf("blueprint = %+v", d)
}
if d := Decide(mk(intentgate.ActionBlueprint, "bp-1"), Env{}); d.Kind != KindTurn || d.Reason == "" {
t.Fatalf("blueprint without squad = %+v", d)
}
}
func TestDecideNonMatchedAndLegacyFallback(t *testing.T) {
if d := Decide(intentgate.MatchResult{Branch: "fallback"}, Env{}); d.Kind != KindTurn {
t.Fatalf("fallback = %+v", d)
}
// matched 但命令列表为空(旧消费方):按 TopID 走 auto,与现网一致。
d := Decide(intentgate.MatchResult{Branch: "matched", ID: "demo.x"}, Env{HasCommand: func(string) bool { return true }})
if d.Kind != KindCommand || d.StartFound {
t.Fatalf("legacy auto = %+v", d)
}
}
func TestSlotsFromStartCommand(t *testing.T) {
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
Commands: []intentgate.Command{{
Kind: intentgate.CommandStart,
Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.x"},
Slots: map[string]string{"topic": "电机"},
}}}
d := Decide(in, Env{HasCommand: func(string) bool { return true }})
if d.Slots["topic"] != "电机" {
t.Fatalf("slots = %v", d.Slots)
}
}
+82
View File
@@ -0,0 +1,82 @@
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) 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)
}
}
+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)
}
}
+82
View File
@@ -0,0 +1,82 @@
package runmgr
import (
"context"
"time"
"agent-native/runtime/memory"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。
// owner/项目态由适配器持有(端口本身不传租户态)。
type storeMemory struct {
st *store.Store
owner string
projectID string
}
// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。
func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store {
if owner == "" {
owner = store.OwnerDefault
}
return &storeMemory{st: st, owner: owner, projectID: projectID}
}
// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义),
// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。
func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
projectID := ""
if q.Scope == "project" {
projectID = m.projectID
}
list, err := m.st.ListMemory(m.owner, q.Scope, projectID)
if err != nil {
return nil, err
}
limit := q.Limit
if limit <= 0 {
limit = memory.MaxRecallEntries
}
out := make([]memory.Entry, 0, len(list))
for _, e := range list {
out = append(out, memory.Entry{
ID: e.ID, Scope: e.Scope, ProjectID: string(e.ProjectID), Type: e.Type,
Content: e.Content, Source: e.Source, Confidence: e.Confidence,
CreatedAt: e.CreatedAt, ExpiresAt: e.ExpiresAt,
})
if len(out) >= limit {
break
}
}
return out, nil
}
// StoreMemory 是 runCtx 的端口出口(llm_direct 经接口断言取用)。
func (c *runCtx) StoreMemory() memory.Store {
if c.mgr == nil || c.mgr.st == nil {
return nil
}
return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID)
}
// Append 写回一条(project scope 用适配器的项目上下文)。
func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error {
projectID := e.ProjectID
if e.Scope == "project" && projectID == "" {
projectID = m.projectID
}
now := time.Now().UTC()
created := e.CreatedAt
if created.IsZero() {
created = now
}
_, err := m.st.PutMemory(m.owner, core.MemoryEntry{
ID: e.ID, Scope: e.Scope, ProjectID: core.ID(projectID), Type: e.Type,
Content: e.Content, Source: e.Source, Confidence: e.Confidence,
CreatedAt: created, ExpiresAt: e.ExpiresAt,
})
return err
}
+94
View File
@@ -0,0 +1,94 @@
package runmgr
import (
"context"
"path/filepath"
"testing"
"time"
"agent-native/runtime/memory"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// ---- N3 WP5:storeMemory 适配器 ----
func openMemStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestStoreMemoryRecallScopeAndLimit(t *testing.T) {
st := openMemStore(t)
put := func(scope, project, typ, content string) {
t.Helper()
if _, err := st.PutMemory("u1", core.MemoryEntry{Scope: scope, ProjectID: core.ID(project), Type: typ, Content: content, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
}
put("user", "", "fact", "用户事实")
put("project", "p1", "decision", "项目决定")
put("project", "p2", "decision", "别的项目")
m := NewStoreMemory(st, "u1", "p1")
// 空 scope = 全量(迁移前 Memories() 语义)。
all, err := m.Recall(context.Background(), memory.RecallQuery{})
if err != nil || len(all) != 3 {
t.Fatalf("全量召回:%d %v", len(all), err)
}
// user scope。
users, _ := m.Recall(context.Background(), memory.RecallQuery{Scope: "user"})
if len(users) != 1 || users[0].Content != "用户事实" {
t.Fatalf("user scope:%+v", users)
}
// project scope 用适配器的项目上下文(p1,不含 p2)。
projs, _ := m.Recall(context.Background(), memory.RecallQuery{Scope: "project"})
if len(projs) != 1 || projs[0].Content != "项目决定" {
t.Fatalf("project scope:%+v", projs)
}
// Limit。
limited, _ := m.Recall(context.Background(), memory.RecallQuery{Limit: 2})
if len(limited) != 2 {
t.Fatalf("Limit:%d", len(limited))
}
}
func TestStoreMemoryAppendRoundtrip(t *testing.T) {
st := openMemStore(t)
m := NewStoreMemory(st, "u1", "p1")
future := time.Now().Add(48 * time.Hour)
if err := m.Append(context.Background(), memory.Entry{
Scope: "project", Type: "procedure", Content: "先跑 lint", Source: "agent", ExpiresAt: &future,
}); err != nil {
t.Fatal(err)
}
list, err := st.ListMemory("u1", "project", "p1")
if err != nil || len(list) != 1 {
t.Fatalf("回读:%d %v", len(list), err)
}
got := list[0]
if got.Type != "procedure" || got.Content != "先跑 lint" || got.Source != "agent" ||
got.ProjectID != "p1" || got.ExpiresAt == nil {
t.Fatalf("写入形态:%+v", got)
}
}
// runCtx 出口:同一 store 上的 runCtx 暴露同一端口语义。
func TestRunCtxStoreMemory(t *testing.T) {
c, st := newSpawnEnv(t, &gateRunner{})
if _, err := st.PutMemory(c.run.Owner, core.MemoryEntry{Scope: "user", Type: "fact", Content: "跨会话事实", CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
port := c.StoreMemory()
if port == nil {
t.Fatal("StoreMemory 必须非空")
}
entries, err := port.Recall(context.Background(), memory.RecallQuery{})
if err != nil || len(entries) != 1 || entries[0].Content != "跨会话事实" {
t.Fatalf("runCtx 召回:%+v %v", entries, err)
}
}
+52
View File
@@ -1,8 +1,12 @@
package runmgr
import (
"context"
"testing"
agentllm "agent-native/runtime/llm"
agentpolicy "agent-native/runtime/policy"
core "agent-system-core"
)
@@ -36,3 +40,51 @@ func TestDefaultModeStillRunsWrite(t *testing.T) {
t.Fatalf("default write: %+v", res)
}
}
// TestConvAllowStoreKeysByName:SessionAllows 端口的键控口径保持「会话 × 工具名」,
// 不区分参数——与旧 sessionAllows 布尔参数的行为一致。
func TestConvAllowStoreKeysByName(t *testing.T) {
c, _ := newExecEnv(t)
conv := c.run.ConversationID
store := convAllowStore{mgr: c.mgr, conv: conv}
pol := agentpolicy.Policy{
Rules: []agentpolicy.Rule{{Effect: agentpolicy.EffectAsk, Tool: "run_shell"}},
SessionAllows: store,
}
if got := pol.Decide("run_shell", `{"command":"ls"}`, agentpolicy.Traits{}); got != agentpolicy.EffectAsk {
t.Fatalf("before remember: %q, want ask", got)
}
store.Remember("run_shell", `{"command":"ls"}`)
if got := pol.Decide("run_shell", `{"command":"rm -rf /tmp/x"}`, agentpolicy.Traits{}); got != agentpolicy.EffectAllow {
t.Fatalf("after remember, other args: %q, want allow(按名放行)", got)
}
if !c.mgr.sessionAllows(conv, "run_shell") {
t.Fatal("remember did not reach the manager table")
}
}
// TestPermissionAskRoutesToHITL:内核 ask 决策经 HITL 确认卡裁决;
// session 选项落会话放行,取消给出可读理由。
func TestPermissionAskRoutesToHITL(t *testing.T) {
c, _ := newExecEnv(t)
c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)}
call := agentllm.ToolCall{ID: "c1", Name: "run_shell", Arguments: `{"command":"ls"}`}
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "yes"}}
if v := c.PermissionAsk(context.Background(), call); !v.Allow {
t.Fatalf("yes verdict = %+v", v)
}
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "session"}}
if v := c.PermissionAsk(context.Background(), call); !v.Allow {
t.Fatalf("session verdict = %+v", v)
}
if !c.mgr.sessionAllows(c.run.ConversationID, "run_shell") {
t.Fatal("session choice did not remember the allow")
}
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "no"}}
if v := c.PermissionAsk(context.Background(), call); v.Allow || v.Reason == "" {
t.Fatalf("no verdict = %+v", v)
}
}
+7 -2
View File
@@ -2,6 +2,7 @@ package runmgr
import (
"fmt"
"log"
"path/filepath"
"strings"
@@ -75,14 +76,18 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul
if err != nil {
return checkpoint.Result{}, err
}
log, err := m.OpenCheckpoints(owner, convID)
cpLog, err := m.OpenCheckpoints(owner, convID)
if err != nil {
return checkpoint.Result{}, err
}
res, err := log.Rewind(messageID, ordered)
res, err := cpLog.Rewind(messageID, ordered)
if err != nil {
return checkpoint.Result{}, err
}
// 保留策略(N3 WP3):成功的回滚后按 anchor 数裁到 200,日志有界。
if cerr := cpLog.Compact(200); cerr != nil {
log.Printf("[runmgr] checkpoint compact %s: %v", convID, cerr)
}
note := rewindNote(res)
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
+276 -35
View File
@@ -5,6 +5,7 @@ package runmgr
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -15,14 +16,18 @@ import (
"time"
"unicode/utf8"
nruntime "agent-native/runtime"
"agent-native/runtime/filestate"
agentllm "agent-native/runtime/llm"
agentpolicy "agent-native/runtime/policy"
"agent-native/runtime/subagent"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/route"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
@@ -42,15 +47,20 @@ type Manager struct {
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
mockSub runtime.Runner // 子代理脚本回放
squad runtime.Runner // runtime.entry=squad 时的对话入口
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.WorkflowRunner
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.CompiledWorkflowRunner
workflows *workflow.Registry // run_workflow 按 id 查找已注册定义
gate IntentGate
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
@@ -78,6 +88,7 @@ const maxBackgroundChildren = 3
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
type bgNotice struct {
id string // 持久化队列主键(重启重投凭据)
owner string
name string
payload core.AgentResultPayload
@@ -87,15 +98,19 @@ type bgNotice struct {
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
cacheWriteTokens int
}
type hitlAnswer struct {
@@ -144,6 +159,15 @@ 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 }
@@ -151,6 +175,46 @@ func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
// 实际会话沙箱为 <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
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 {
@@ -231,10 +295,15 @@ func (c *runCtx) ConversationSandbox() string {
}
// AddUsage 累加本 run 的模型用量,finishRun 写入 run.Usage。
// 计费拆分(N3 WP6):Last* 覆盖(最近一次请求)、Cache* 累计。
func (c *runCtx) AddUsage(u core.Usage) {
c.mgr.mu.Lock()
c.h.promptTokens += u.PromptTokens
c.h.completionTokens += u.CompletionTokens
c.h.lastPromptTokens = u.LastPromptTokens
c.h.lastCompletionTokens = u.LastCompletionTokens
c.h.cacheReadTokens += u.CacheReadTokens
c.h.cacheWriteTokens += u.CacheWriteTokens
c.mgr.mu.Unlock()
}
func (c *runCtx) Subtask() string { return c.subTask }
@@ -286,8 +355,73 @@ func (m *Manager) sessionAllows(conv, tool string) bool {
return m.sessionAllow[conv][tool]
}
// convAllowStore 把 Manager 的会话放行表呈现为 agentpolicy.SessionAllows 端口。
// 键控保持既有口径:会话 × 工具名(同名调用不再询问),不区分参数。
type convAllowStore struct {
mgr *Manager
conv string
}
func (s convAllowStore) Remember(name, args string) {
s.mgr.rememberSessionAllow(s.conv, name)
}
func (s convAllowStore) Allowed(name, args string) bool {
return s.mgr.sessionAllows(s.conv, name)
}
// toolConfirmCard 是工具确认 HITL 卡的统一形态:ExecuteTool 的画像确认与
// 内核 PermissionAsk 端口共用,两条路径的选项与口径保持一致。
func toolConfirmCard(name, args string) core.HITLCard {
var argsPreview string
if len(args) > 0 {
argsPreview = truncate(args, 600)
}
return core.HITLCard{
Question: fmt.Sprintf("执行工具 %s?", name), Kind: core.HITLKindConfirm, Detail: argsPreview,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
},
AllowFreeInput: false,
}
}
// PermissionAsk 把内核钩子的 ask 决策路由到 HITL 确认卡(N1 WP2 宿主侧)。
// 「本会话始终允许」沿用 ExecuteTool 的会话放行口径(会话 × 工具名)。
func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict {
resp, err := c.AskHITL(toolConfirmCard(call.Name, call.Arguments))
if err != nil {
return nruntime.AskVerdict{Reason: "hitl aborted: " + err.Error()}
}
switch resp.ChoiceID {
case "session":
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
return nruntime.AskVerdict{Allow: true}
case "yes":
return nruntime.AskVerdict{Allow: true}
default:
return nruntime.AskVerdict{Reason: "用户取消了本次工具调用"}
}
}
func (c *runCtx) Tools() []core.ToolDesc {
list := c.mgr.registry.List()
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.CommandTools() {
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)
@@ -666,6 +800,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)
@@ -678,29 +819,18 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
}
desc, _, _ := c.mgr.registry.Get(call.Name)
effect := agentpolicy.Decide(agentpolicy.Request{
Mode: agentpolicy.Normalize(c.permissionMode), Name: call.Name, Args: string(call.Arguments),
Traits: agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk},
SessionAllow: c.mgr.sessionAllows(c.run.ConversationID, call.Name),
}, nil)
// N1 WP3:判定走 Policy 对象,会话放行经 SessionAllows 端口(键控口径不变:会话 × 工具名)。
pol := agentpolicy.Policy{
Mode: agentpolicy.Normalize(c.permissionMode),
SessionAllows: convAllowStore{mgr: c.mgr, conv: c.run.ConversationID},
}
effect := pol.Decide(call.Name, string(call.Arguments),
agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk})
if effect == agentpolicy.EffectDeny {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("计划模式拒绝了工具 %s", call.Name)}
}
if effect != agentpolicy.EffectAllow && (effect == agentpolicy.EffectAsk || policy.NeedsConfirm(call.Name)) {
var argsPreview string
if len(call.Arguments) > 0 {
argsPreview = truncate(string(call.Arguments), 600)
}
card := core.HITLCard{
Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm, Detail: argsPreview,
Choices: []core.HITLChoice{
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
},
AllowFreeInput: false,
}
resp, err := c.AskHITL(card)
resp, err := c.AskHITL(toolConfirmCard(call.Name, string(call.Arguments)))
if err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
}
@@ -710,6 +840,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.CommandTools() {
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
@@ -866,7 +1023,7 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo
if m.wfRunner != nil {
return m.wfRunner
}
return runtime.WorkflowRunner{}
return runtime.CompiledWorkflowRunner{}
}
if eff.GetBool("runtime.mock", false) {
return m.mock
@@ -985,6 +1142,8 @@ 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)
return run, nil
@@ -1035,6 +1194,10 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage,
usage.PromptTokens = rc.h.promptTokens
usage.CompletionTokens = rc.h.completionTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage.LastPromptTokens = rc.h.lastPromptTokens
usage.LastCompletionTokens = rc.h.lastCompletionTokens
usage.CacheReadTokens = rc.h.cacheReadTokens
usage.CacheWriteTokens = rc.h.cacheWriteTokens
m.mu.Unlock()
if m.shuttingDown.Load() && status == core.RunCancelled {
status = core.RunInterrupted
@@ -1227,12 +1390,21 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
answer = resp.FreeText
trimmed := strings.TrimSpace(resp.FreeText)
// 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。
// 命中与否经唯一 Router(internal/route)判定,与 chat 路由同一裁决(I1 WP3)。
pivot := false
pivotReason := ""
if strings.HasPrefix(trimmed, "/") {
pivot, pivotReason = true, "command"
} else if m.gate != nil {
if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
if rec, ok := m.gate.(interface {
Recognize(ctx context.Context, input string) intentgate.MatchResult
}); ok {
res := rec.Recognize(context.Background(), trimmed)
dec := route.Decide(res, route.Env{})
if dec.StartFound {
pivot, pivotReason = true, "intent:"+res.ID
}
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
pivot, pivotReason = true, "intent:"+topID
}
}
@@ -1303,6 +1475,8 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID
if err := m.st.CreateRun(&child); err != nil {
return nil, nil, err
}
// 独立转录(N2 WP5):派生请求落盘,续跑的重建凭据。
m.writeSubagentRequest(child, agentID, name, task, hint, detach)
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
base := parentCtx
@@ -1339,7 +1513,17 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
return runner.Execute(rc)
}()
// 子代理记账(N3 WP6):token 用量随子 run 入账(此前只有 DurationMs)。
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
m.mu.Lock()
usage.PromptTokens = rc.h.promptTokens
usage.CompletionTokens = rc.h.completionTokens
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
usage.LastPromptTokens = rc.h.lastPromptTokens
usage.LastCompletionTokens = rc.h.lastCompletionTokens
usage.CacheReadTokens = rc.h.cacheReadTokens
usage.CacheWriteTokens = rc.h.cacheWriteTokens
m.mu.Unlock()
status := core.RunCompleted
summary := "完成"
payload := core.AgentResultPayload{AgentRun: rc.run.ID, Status: status, Summary: summary}
@@ -1368,6 +1552,8 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool
m.mu.Lock()
subParts := append([]core.Part{}, rc.h.assistant...)
m.mu.Unlock()
// 独立转录落定(含终态 done 标记,供续跑幂等判定)。
m.writeSubagentTranscript(child, subParts, string(status))
fullText := partsText(subParts)
if len(subParts) > 0 {
_ = m.st.AppendMessage(&core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
@@ -1432,6 +1618,15 @@ func (m *Manager) decBackground(convID string) {
}
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
// 先落库(重启重投凭据),再进内存热队列;落库失败不丢内存路径。
if n.id == "" {
n.id = store.NewID("ntc")
}
if err := m.st.AppendNotice(store.Notice{
ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload,
}); err != nil {
log.Printf("[runmgr] notice persist %s: %v", convID, err)
}
m.noticeMu.Lock()
defer m.noticeMu.Unlock()
if m.notices == nil {
@@ -1440,6 +1635,31 @@ func (m *Manager) enqueueNotice(convID string, n bgNotice) {
m.notices[convID] = append(m.notices[convID], n)
}
// LoadPendingNotices 启动时把未投递的通知装回内存队列并尝试投递
// (N2 WP5:重启重投)。投递遵循既有空闲语义(忙则等下一轮触发)。
func (m *Manager) LoadPendingNotices() {
items, err := m.st.ListNotices()
if err != nil {
log.Printf("[runmgr] notice load: %v", err)
return
}
byConv := map[string]bool{}
m.noticeMu.Lock()
if m.notices == nil {
m.notices = map[string][]bgNotice{}
}
for _, it := range items {
m.notices[it.ConversationID] = append(m.notices[it.ConversationID], bgNotice{
id: it.ID, owner: it.Owner, name: it.Name, payload: it.Payload,
})
byConv[it.ConversationID] = true
}
m.noticeMu.Unlock()
for convID := range byConv {
m.deliverNotices(convID)
}
}
// deliverNotices 在会话空闲时把全部待投递结果合并成一条 system 消息,并启动新的父回合。
// 会话忙、或启动时发现忙,就把结果放回队列。
func (m *Manager) deliverNotices(convID string) {
@@ -1484,6 +1704,17 @@ func (m *Manager) deliverNotices(convID string) {
}
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)
}
}
@@ -1544,6 +1775,16 @@ func (m *Manager) InterruptRunning() {
h.cancel()
}
for _, id := range ids {
// 子代理 run 先把已收集的部分转录落定(中断可续跑的现场)。
if run, err := m.st.GetRunByID(id); err == nil && run.Kind == core.RunSubagent {
m.mu.Lock()
var parts []core.Part
if h, ok := m.active[id]; ok {
parts = append(parts, h.assistant...)
}
m.mu.Unlock()
m.writeSubagentTranscript(run, parts, "interrupted")
}
m.sealRunInterrupted(id)
}
}
@@ -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)
}
}
+223
View File
@@ -0,0 +1,223 @@
package runmgr
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// ---- N2 WP5:子代理独立转录、中断续跑、通知持久化 ----
// 派生即落 request.json;完成时 transcript.jsonl + done.json 落定。
func TestSubagentTranscriptFilesWritten(t *testing.T) {
c, _ := newSpawnEnv(t, &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "结果文本"}}})
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
res, err := c.SpawnSubagent("检索", "查资料", "提示语")
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(ws, c.run.Owner, c.run.ConversationID, "subagents", res.AgentRun)
reqBody, err := os.ReadFile(filepath.Join(dir, "request.json"))
if err != nil {
t.Fatalf("request.json: %v", err)
}
if !strings.Contains(string(reqBody), "查资料") {
t.Fatalf("request.json 内容:%s", reqBody)
}
trBody, err := os.ReadFile(filepath.Join(dir, "transcript.jsonl"))
if err != nil || !strings.Contains(string(trBody), "结果文本") {
t.Fatalf("transcript.jsonl:%v %s", err, trBody)
}
doneBody, err := os.ReadFile(filepath.Join(dir, "done.json"))
if err != nil || !strings.Contains(string(doneBody), "completed") {
t.Fatalf("done.json:%v %s", err, doneBody)
}
}
// 中断子 run 凭 request.json 续跑:同任务重派、hint 带前次转录路径、
// resumed.json 防重复续跑。
func TestResumeInterruptedChildrenRespawns(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)
// 父 run 落库(续跑要经 store 取回派生上下文)。
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
// 造一个中断的子 run 现场(等价于崩溃前的派生)。
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)
}
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)
}
// resumed.json 幂等:第二次调用不再新增。
c.mgr.ResumeInterruptedChildren()
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids2) != len(ids) {
t.Fatalf("重复续跑:%v → %v", ids, ids2)
}
}
// 通知先落库后投递;模拟重启后 LoadPendingNotices 重投并清库。
func TestNoticesPersistAndRedeliverAfterRestart(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "后台结果"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
// 会话占线:通知只能入库不能投递。
if err := c.mgr.claimConversation(c.run.ConversationID); err != nil {
t.Fatal(err)
}
if _, err := c.SpawnBackground("agent.sub.general", "甲", "任务甲", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
items, err := st.ListNotices()
return err == nil && len(items) == 1
})
// 模拟重启:同一 store 上的新 Manager 只接通知面(会话仍忙时不应丢)。
c.mgr.releaseConversation(c.run.ConversationID)
m2 := New(st, c.mgr.bus, nil, c.mgr.profiles, c.mgr.settings, noopRunner{}, runner, nil, nil)
m2.LoadPendingNotices()
waitUntil(t, func() bool {
items, err := st.ListNotices()
return err == nil && len(items) == 0
})
texts := systemTexts(t, c)
if len(texts) == 0 || !strings.Contains(texts[len(texts)-1], "后台结果") {
t.Fatalf("重投的通知正文缺失:%v", texts)
}
}
// 适配层 Background 分支:Request.Background=true → SpawnBackground(语义
// 与产品 delegate 的 background 参数一致)。在本包内用 runCtx 直验。
func TestSpawnBackgroundRecordsDetach(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "bg"}}}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
id, err := c.SpawnBackground("agent.sub.general", "后台", "任务", "")
if err != nil {
t.Fatal(err)
}
run, err := c.mgr.st.GetRunByID(id)
if err != nil {
t.Fatal(err)
}
if run.Kind != core.RunSubagent || run.Status != core.RunCompleted && run.Status != core.RunRunning {
t.Fatalf("run = %+v", run)
}
}
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
// completed 终态挡续跑。
func TestResumeAfterGracefulInterruptMarker(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)
// 模拟优雅停机:partial 转录 + done.json{status:"interrupted"}。
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "半截现场"}}, "interrupted")
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, _ := st.SubagentRunIDs(c.run.ConversationID)
c.mgr.ResumeInterruptedChildren()
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids) != len(before)+1 {
t.Fatalf("优雅停机现场应被续跑一次:before=%v after=%v", before, ids)
}
c.mgr.ResumeInterruptedChildren()
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids2) != len(ids) {
t.Fatalf("重复续跑:%v → %v", ids, ids2)
}
}
func TestResumeSkipsCompletedMarker(t *testing.T) {
c, st := newSpawnEnv(t, &gateRunner{})
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)
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "已完成"}}, "completed")
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, _ := st.SubagentRunIDs(c.run.ConversationID)
c.mgr.ResumeInterruptedChildren()
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids) != len(before) {
t.Fatalf("completed 终态不得续跑:before=%v after=%v", before, ids)
}
}
// N3 WP6:子代理记账链——AddUsage 累计拆分字段,finishChild 合并进 child.Usage。
func TestAddUsageAccumulatesSplitFields(t *testing.T) {
c, _ := newSpawnEnv(t, &gateRunner{})
c.h = &runHandle{}
c.AddUsage(core.Usage{PromptTokens: 100, CompletionTokens: 10, LastPromptTokens: 100, LastCompletionTokens: 10, CacheReadTokens: 50, CacheWriteTokens: 4})
c.AddUsage(core.Usage{PromptTokens: 60, CompletionTokens: 6, LastPromptTokens: 60, LastCompletionTokens: 6, CacheReadTokens: 20, CacheWriteTokens: 2})
c.mgr.mu.Lock()
defer c.mgr.mu.Unlock()
if c.h.promptTokens != 160 || c.h.completionTokens != 16 ||
c.h.lastPromptTokens != 60 || c.h.lastCompletionTokens != 6 ||
c.h.cacheReadTokens != 70 || c.h.cacheWriteTokens != 6 {
t.Fatalf("记账链:%+v", c.h)
}
}
+174
View File
@@ -0,0 +1,174 @@
package runmgr
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
core "agent-system-core"
)
// subagent_transcript.go — 子代理独立转录与中断续跑(N2 WP5)。
// 每个子 run 一个目录:<workspaceRoot>/<owner>/<conv>/subagents/<runID>/
// request.json 派生请求(agentID/name/task/hint/detach),续跑的重建凭据
// transcript.jsonl 按 part 追加的转录(完成/中断时落定)
// done.json 终态标记(resume 幂等:有它不再续跑)
// resumed.json 已续跑标记(指向新 run)
// subagentDir 返回子 run 的转录目录(未配置 workspaceRoot 时为空,写盘静默跳过)。
func (m *Manager) subagentDir(owner, convID, runID string) string {
if m.workspaceRoot == "" {
return ""
}
return filepath.Join(m.workspaceRoot, filepath.Clean(owner), filepath.Clean(convID), "subagents", filepath.Clean(runID))
}
// subagentRequest 是 request.json 的内容。
type subagentRequest struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
Task string `json:"task"`
Hint string `json:"hint,omitempty"`
Detach bool `json:"detach"`
CreatedAt string `json:"createdAt"`
}
// writeSubagentRequest 在派生时落 request.json 与空转录(失败只记日志,
// 不阻断派生——转录是辅助面)。
func (m *Manager) writeSubagentRequest(run core.Run, agentID, name, task, hint string, detach bool) {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Printf("[runmgr] subagent dir %s: %v", run.ID, err)
return
}
body, _ := json.MarshalIndent(subagentRequest{
AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, "", " ")
if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil {
log.Printf("[runmgr] subagent request %s: %v", run.ID, err)
}
// 转录文件先建(append 语义清晰可见)。
if _, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644); err != nil {
log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err)
}
}
// writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。
// status 是终态(completed/failed/cancelled/interrupted)。
func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
log.Printf("[runmgr] transcript open %s: %v", run.ID, err)
return
}
enc := json.NewEncoder(f)
for i, p := range parts {
_ = enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)})
}
_ = f.Close()
done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)})
if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil {
log.Printf("[runmgr] done marker %s: %v", run.ID, err)
}
}
// doneStatus 读 done.json 的 status(无文件或不可读按 "interrupted" 处理——
// 缺标记本身就是中断现场)。
func doneStatus(dir string) string {
body, err := os.ReadFile(filepath.Join(dir, "done.json"))
if err != nil {
return "interrupted"
}
var done struct {
Status string `json:"status"`
}
if json.Unmarshal(body, &done) != nil || done.Status == "" {
return "interrupted"
}
return done.Status
}
// readSubagentRequest 读 request.json(无文件返回 nil, nil)。
func readSubagentRequest(dir string) (*subagentRequest, error) {
body, err := os.ReadFile(filepath.Join(dir, "request.json"))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var req subagentRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("request.json: %w", err)
}
return &req, nil
}
// ResumeInterruptedChildren 重启后扫描被中断的子代理 run:有派生请求且
// 未完成未续跑的,用原请求重新派生(后台),并把前次转录路径写进 hint
// 供模型读取后继续(N2 WP5 的「可恢复」)。幂等:done/resumed 标记跳过。
func (m *Manager) ResumeInterruptedChildren() {
runs, err := m.st.ListInterruptedSubagentRuns()
if err != nil {
log.Printf("[runmgr] resume scan: %v", err)
return
}
for _, run := range runs {
dir := m.subagentDir(run.Owner, run.ConversationID, run.ID)
if dir == "" {
continue
}
// done.json 语义分流(N3 WP1):终态(completed/failed/cancelled)挡续跑;
// interrupted 不挡——优雅停机与崩溃都是可续跑现场。
if doneStatus(dir) != "interrupted" || fileExists(filepath.Join(dir, "resumed.json")) {
continue
}
req, err := readSubagentRequest(dir)
if err != nil {
log.Printf("[runmgr] resume read %s: %v", run.ID, err)
continue
}
if req == nil {
continue // WP5 之前的旧 run 没有重建凭据,保持 interrupted 终态
}
parent, err := m.st.GetRunByID(run.ParentRunID)
if err != nil {
log.Printf("[runmgr] resume parent %s: %v", run.ParentRunID, err)
continue
}
hint := req.Hint + "\n(本任务是前次中断后的自动续跑;前次转录在 " +
filepath.Join(dir, "transcript.jsonl") + ",可读取后继续。)"
newID, err := m.spawnBackground(parent, req.AgentID, req.Name, req.Task, hint)
if err != nil {
log.Printf("[runmgr] resume spawn %s: %v", run.ID, err)
continue
}
marker, _ := json.Marshal(map[string]string{
"newRun": newID,
"resumedAt": time.Now().UTC().Format(time.RFC3339),
})
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
log.Printf("[runmgr] resumed marker %s: %v", run.ID, err)
}
log.Printf("[runmgr] resumed subagent %s as %s", run.ID, newID)
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return 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])
}
}
+162
View File
@@ -0,0 +1,162 @@
package runtime
import (
"regexp"
"strings"
core "agent-system-core"
"agent-system-backend/internal/quality"
)
// 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(白名单:豁免的通用口径词)。
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)) {
out = append(out, tok)
}
}
return out
}
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("。逐条二选一:改成材料里能找到的数值,或删除/明确标注为推测或目标值(如“目标 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% 确定)与零值
}
}
+65
View File
@@ -0,0 +1,65 @@
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
}
+142 -56
View File
@@ -5,16 +5,18 @@ import (
"fmt"
"math"
"net/http"
"regexp"
"strings"
"time"
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"
core "agent-system-core"
)
@@ -67,8 +69,10 @@ const (
toolCardCommit = "card_commit"
)
// 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))
@@ -96,7 +100,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 的"显式引用"语义),独立成块。
@@ -107,10 +111,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))
@@ -118,7 +126,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 把设置里的默认主题、强调色和模板压成环境段的一句话。
@@ -137,6 +145,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 {
@@ -200,12 +230,20 @@ 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)
retries := int(eff.GetNumber("runtime.llmRetries", 2))
stream = agentllm.WithRetry(client.Stream, retries, nil)
stream = client.Stream
}
// 知识注入:当前消息包含 @knowledge 引用时检索注入(最小实现:显式引用)。
@@ -222,15 +260,18 @@ 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) }
if ms, ok := rc.(interface{ Memories() []core.MemoryEntry }); ok {
sys = ctxasm.InjectMemory(sys, ms.Memories())
}
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
// 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入,
// 组装侧不再注入(防双份;注入文案同一形态)。
_ = ctxasm.InjectMemory
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
ThinkReplay: thinkReplayEnabled(eff, route.Model),
})
tail := ctxasm.Tail{
Instructions: eff.GetString("context.userInstructions", ""),
History: hist,
@@ -316,7 +357,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 {
@@ -359,13 +406,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) {
@@ -398,6 +465,28 @@ func outputTokenCap(budget core.RunBudget, eff core.EffectiveSettings, key strin
return 0
}
// memoryStore 是记忆端口的最小面(agent-native runtime/memory.Store 的本地别名;
// 组合根经 runCtx 注入,runmgr 的 storeMemory 实现之)。
type memoryStore = agentmemory.Store
// retryAttemptsOf 把 runtime.llmRetries 的设置语义(0=不重试,N=N 次)
// 映射到库 Config.RetryAttempts(0=默认 2,负值=关,正数=次数)。
func retryAttemptsOf(eff core.EffectiveSettings) int {
n := int(eff.GetNumber("runtime.llmRetries", 2))
if n <= 0 {
return -1
}
return n
}
// agentNameOf 是 OTel gen_ai.agent.name 的取值(画像 ID,空回退 agent-system)。
func agentNameOf(prof core.AgentProfile) string {
if strings.TrimSpace(prof.ID) != "" {
return prof.ID
}
return "agent-system"
}
// openLLMClient 不设 HTTP 客户端超时。单次生成可能很长,停止按钮通过 run 的 context 取消。
func openLLMClient(base, key string) *agentllm.Client {
return &agentllm.Client{BaseURL: base, APIKey: key, HTTPClient: &http.Client{}}
@@ -453,61 +542,58 @@ func orDefault(s, def string) string {
return s
}
const maxDeliverableGuards = 2
const deliverableGuardNote = "你的上一段回复声称已经生成或登记了文件,但这一轮没有产生对应的工件。请二选一:用 run_python 写出文件并调用 register_artifact 登记,或如实说明这一轮没有生成文件。不要编造工件 ID,也不要在回复里写 art_ 开头的编号。正文里的「调用工具」「工具结果」「产出工件」字样不会执行任何东西,调用工具必须用函数调用。"
const discardedClaimNote = "(上一段回复声称已交付,但本轮没有任何工具调用,已作废)"
const deliverableFallbackNotice = "本轮未实际生成文件(模型声称已生成,但没有调用工具)。请重试,或换一种说法再试一次。"
var (
artIDRe = regexp.MustCompile(`art_[0-9a-f]{12}`)
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
)
// claimsFalseDeliverable 保留给小队入口:命中即判失败,不走直连路径的纠正轮次。
func claimsFalseDeliverable(body string, produced []*core.ArtifactRef, rc RunCtx) bool {
block, _ := deliverableGate(body, produced, rc)
block, _ := deliverableGateOf(body, produced, rc)
return block
}
// deliverableGate 判断终轮正文是否在没有对应工件的情况下声称交付。
// 返回值的形状与 Stop 门禁一致,P1 平移进内核时不用再改调用方。
// 引用本轮已产出、或项目里确实存在的工件,不算谎称;正文里的伪造调用标记始终拦截。
func deliverableGate(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
if strings.TrimSpace(body) == "" {
return false, ""
}
if forgedCallRe.MatchString(body) {
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
}
producedIDs := map[string]bool{}
for _, ref := range produced {
if ref != nil && ref.ArtifactID != "" {
producedIDs[ref.ArtifactID] = true
}
}
knownExisting := false
for _, id := range artIDRe.FindAllString(body, -1) {
if producedIDs[id] {
// deliverableGateOf 把产品工件存取投影成库端口后调用下沉的 ClaimGate(N1 WP5)。
func deliverableGateOf(body string, produced []*core.ArtifactRef, rc RunCtx) (bool, string) {
return gates.ClaimGate(body, artifactRefsOf(produced), artifactLookupOf(rc))
}
// artifactRefsOf 把 core.ArtifactRef 投影为库的最小形态。
func artifactRefsOf(refs []*core.ArtifactRef) []gates.ArtifactRef {
out := make([]gates.ArtifactRef, 0, len(refs))
for _, ref := range refs {
if ref == nil || ref.ArtifactID == "" {
continue
}
if _, _, ok := rc.Artifact(id); ok {
knownExisting = true
continue
out = append(out, gates.ArtifactRef{ID: ref.ArtifactID, Name: ref.Name, Kind: ref.Kind})
}
return out
}
// artifactLookupOf 把 rc.Artifact 投影为库的工件存取端口。
func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
return func(id string) (string, []byte, bool) {
art, content, ok := rc.Artifact(id)
if !ok {
return "", nil, false
}
return true, "回复引用了不存在的工件"
return art.Name, content, true
}
if len(producedIDs) > 0 || knownExisting {
return false, ""
}
// hardFactScorer 把 quality.ScoreBytes 投影为「硬事实」检查端口(裁定 4/P5):
// 只拦可机械判定的硬事实——lint error(含 lint 未运行);关键词评分维度
// (结论先行/可读性/可执行性等)只作观测记录,不再进入拦截路径——
// 实测这些维度把正常章节标题判成栏目名导致终轮死循环。
func hardFactScorer() gates.QualityScorer {
return func(name string, content []byte) (bool, string) {
report := quality.HardFacts(name, content)
if report.Passed {
return true, ""
}
return false, strings.Join(report.Violations, ";")
}
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
}
return false, ""
}
func summarize(out string, err error) string {
+253 -47
View File
@@ -4,17 +4,23 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"path/filepath"
"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"
"agent-native/runtime/reminders"
agentsession "agent-native/runtime/session"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/wfcompile"
core "agent-system-core"
)
@@ -26,6 +32,7 @@ type directRun struct {
rc RunCtx
eff core.EffectiveSettings
cardflow bool
memoryStore memoryStore
finalParts []core.Part
explicitCard bool
deliverables []*core.ArtifactRef
@@ -45,18 +52,19 @@ type directRun struct {
inflight *toolSlot
turnBody string
evidence []toolEvidence
todos core.TodoList
plan core.PlanSnapshot
closedToolTurn bool
checklistTouched bool
toolTurnsSince int
checklistReminders int
checks []core.CheckPart
notes *reminders.Bus
servedModel string
switches []string
switchMu sync.Mutex
evidence []toolEvidence
todos core.TodoList
plan core.PlanSnapshot
closedToolTurn bool
checklistTouched bool
toolTurnsSince int
nudgedPlanless bool
factChecked bool
progress *gates.ProgressReminder
checks []core.CheckPart
servedModel string
switches []string
switchMu sync.Mutex
}
type toolEvidence struct {
@@ -88,8 +96,12 @@ 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 {
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}
// 记忆端口(N3 WP5):runCtx 暴露 store 时接线(注入由循环按请求完成)。
if ms, ok := rc.(interface{ StoreMemory() memoryStore }); ok {
st.memoryStore = ms.StoreMemory()
}
st.loadChecklist()
if msg, ok := lastAddressed(rc.History()); ok && msg.Role == core.RoleSystem {
var text string
@@ -118,12 +130,39 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
return st.runTool(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(res)
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 {
@@ -159,6 +198,62 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
}
window, reserve, buffer := contextWindow(st.eff)
// 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 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
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 {
@@ -173,6 +268,8 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
res, err := nruntime.Run(rc.Context(), nruntime.Config{
Model: model,
History: history,
Session: sessStore,
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
Tools: reg,
Stream: wrapped,
MaxTurns: maxTurns,
@@ -183,25 +280,63 @@ 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,
Trace: l.Trace,
Reminders: func(turn int) []string {
var out []string
out = append(out, st.reminders(turn)...)
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
// 只在非播种 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()
@@ -230,6 +365,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
return nil
}
// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径,
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
func (st *directRun) noteKernelResult(res agenttools.Result) {
if st.inflight == nil {
return
}
summary := summarize(res.Content, nil)
if res.IsError {
summary = "error: " + summary
}
st.inflight.summary = summary
st.inflight.status = statusOf(nil)
if res.IsError {
st.inflight.status = "error"
}
st.inflight.verifyText = tailRunes(res.Content, 600)
}
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
// 执行开始前的这段时间原先没有任何工具事件,界面只剩光标。
func (st *directRun) noteModelSwitch(from, to string) {
@@ -251,6 +404,9 @@ func (st *directRun) noteToolDelta(ev agentllm.Event) {
func (st *directRun) onEvent(ev nruntime.Event) {
switch ev.Type {
case "auto_retry_start":
// 重试可观测(N3 WP6):沿用 process part 形态。
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "模型在输出前失败,正在重试(" + ev.Text + ")", Process: true})
case "turn_start":
st.responding = false
st.acting = false
@@ -290,6 +446,11 @@ func (st *directRun) onEvent(ev nruntime.Event) {
st.usageTotal.PromptTokens += ev.PromptTokens
st.usageTotal.CompletionTokens += ev.CompletionTokens
st.usageTotal.TotalTokens += ev.TotalTokens
// 计费拆分(N3 WP6):上一请求覆盖、缓存命中累计。
st.usageTotal.LastPromptTokens = ev.PromptTokens
st.usageTotal.LastCompletionTokens = ev.CompletionTokens
st.usageTotal.CacheReadTokens += ev.CacheReadTokens
st.usageTotal.CacheWriteTokens += ev.CacheWriteTokens
st.queued = append([]agentllm.ToolCall(nil), ev.Message.ToolCalls...)
hasCalls := len(ev.Message.ToolCalls) > 0
if st.turnThink.Len() > 0 {
@@ -347,12 +508,12 @@ func (st *directRun) onEvent(ev nruntime.Event) {
}
func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntime.StopDecision {
if block, why := deliverableGate(st.turnBody, st.deliverables, st.rc); block {
if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block {
st.demoteLastText()
if st.eff.GetBool("runtime.verify", false) {
st.addVerifyNote(why)
}
if info.Continuations < maxDeliverableGuards {
if info.Continuations < gates.MaxDeliverableGuards {
return nruntime.StopDecision{Continue: true, Replacement: discardedClaimNote, Reason: systemCheckReason(deliverableGuardNote)}
}
return nruntime.StopDecision{Notice: deliverableFallbackNotice}
@@ -360,10 +521,28 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
if !st.eff.GetBool("runtime.verify", false) {
return nruntime.StopDecision{}
}
if reasons := checkDeliverables(st.rc, st.deliverables); 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:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料;
// 找不到出处的给模型一轮修正(改值/删除/标注推测),修正过仍失败的
// 不再拦截(记入检查行如实呈现),避免机械比对的误报变成死循环。
if st.eff.GetBool("runtime.factCheck", true) && len(latest) > 0 && info.Continuations < gates.MaxVerifyContinuations && !st.factChecked {
pairs := st.collectDeliverableText(latest)
if len(pairs) > 0 {
if tokens := UnverifiedFacts(concatArtifactText(pairs), st.materialCorpus(), factExemptions()); len(tokens) > 0 {
st.factChecked = true
st.addVerifyNote("事实核查:" + factCheckNotice(tokens))
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason("终轮事实核查发现以下内容在材料中找不到出处,请修正后重新生成/登记交付物。\n" + factCheckNotice(tokens))}
}
}
}
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, latest)
if err != nil {
st.addVerifyNote(err.Error())
return nruntime.StopDecision{Notice: verifierUnavailableNotice}
@@ -393,6 +572,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)
}
@@ -424,7 +634,7 @@ func (st *directRun) addCheck(passed bool, reason string) {
}
func (st *directRun) continueOrNotice(info nruntime.StopInfo, reason string) nruntime.StopDecision {
if info.Continuations < maxVerifyContinuations {
if info.Continuations < gates.MaxVerifyContinuations {
st.demoteLastText()
st.addVerifyNote(reason)
return nruntime.StopDecision{Continue: true, Reason: systemCheckReason(reason)}
@@ -553,8 +763,8 @@ func (st *directRun) runTool(name, args string) agenttools.Result {
st.inflight.status = status
st.inflight.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}
}
@@ -594,7 +804,9 @@ func (st *directRun) runRegisteredWorkflow(args string) string {
if !ok {
return "未注册的工作流 " + a.Workflow
}
res, err := (WorkflowRunner{}).run(st.rc, registeredWorkflowLaunch(st.rc, def, a.Inputs))
// Q3 WP2:注册工作流经编译图执行(P4 上限在编译入口强制)。
res, err := (CompiledWorkflowRunner{}).runLaunch(st.rc,
registeredWorkflowLaunch(st.rc, def, a.Inputs), wfcompile.RunOptions{})
st.pinSubagentArtifacts(res.Summary)
if err != nil {
if res.Summary != "" {
@@ -606,31 +818,25 @@ func (st *directRun) runRegisteredWorkflow(args string) string {
}
func (st *directRun) runScriptWorkflow(body string) string {
text, arts, err := runModelScript(st.rc, WorkflowRunner{}, body)
var ids []string
for _, art := range arts {
if art != nil {
ids = append(ids, string(art.ArtifactID))
}
}
if len(ids) > 0 {
if text != "" {
text += "\n"
}
text += "工件:" + strings.Join(ids, "、")
}
st.pinSubagentArtifacts(text)
// Q3 WP2:模型脚本包成单步工作流走编译图——runtime.workflowScripts
// 开关与执行前确认由 wfcompile 宿主处理器持有(P4/legacy 同款:模型脚本
// 只有脚本级确认门,不叠加计划确认)。
res, err := (CompiledWorkflowRunner{}).runLaunch(st.rc, modelScriptLaunch(body),
wfcompile.RunOptions{ModelScript: true})
st.pinSubagentArtifacts(res.Summary)
if err != nil {
if text != "" {
return text + "\n" + err.Error()
if res.Summary != "" {
return res.Summary + "\n" + err.Error()
}
return err.Error()
}
return text
return res.Summary
}
func (st *directRun) runPlannedWorkflow(goal string) string {
res, err := planWorkflow(st.rc, goal, WorkflowRunner{})
// Q3 WP2:规划分支——规划子代理产出 WorkflowDef,动态上限编译,
// 执行前强制计划确认(legacy planWorkflow 同款流)。
res, err := planCompiledWorkflow(st.rc, goal, CompiledWorkflowRunner{})
st.pinSubagentArtifacts(res.Summary)
if err != nil {
if res.Summary != "" {
@@ -727,7 +933,7 @@ func (st *directRun) pinSubagentArtifacts(text string) {
seen[ref.ArtifactID] = true
}
}
for _, id := range artIDRe.FindAllString(text, -1) {
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
if seen[id] {
continue
}
+170
View File
@@ -0,0 +1,170 @@
package runtime
import (
"context"
"path/filepath"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
agentsession "agent-native/runtime/session"
core "agent-system-core"
)
// ---- N3 WP2:产品会话路径持久化(验收① 产品层机器证据)----
func textTurns(texts ...string) [][]agentllm.Event {
out := make([][]agentllm.Event, 0, len(texts))
for _, t := range texts {
out = append(out, []agentllm.Event{
{Kind: agentllm.KindTextDelta, Text: t},
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: t, StopReason: agentllm.StopEnd}},
})
}
return out
}
// 杀窗口形态的种子转录:新 run 前配对修复成立,且 run 正常产出。
func TestDirectRunPairsAfterKillSeed(t *testing.T) {
sandbox := t.TempDir()
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, m := range []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "sys"},
{Role: agentllm.RoleUser, Content: "早前"},
{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{{ID: "c1", Name: "run_python", Arguments: `{}`}}},
} {
if err := sess.Append(m); err != nil {
t.Fatal(err)
}
}
rc := &fakeRunCtx{
ctx: context.Background(), sandbox: sandbox,
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
history: []core.Message{
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "还在吗"}}},
},
}
r := &LLMRunner{Stream: scriptStream(textTurns("还在"), nil)}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
loaded, err := sess.Load()
if err != nil {
t.Fatal(err)
}
var paired bool
for _, m := range loaded {
if m.Role == agentllm.RoleTool && m.ToolCallID == "c1" {
paired = true
if !m.IsError || !strings.Contains(m.Content, "进程中断") {
t.Fatalf("兜底配对形态不对:%+v", m)
}
}
}
if !paired {
t.Fatalf("杀窗口调用未配对:%#v", loaded)
}
}
// 转录权威语义(质量 P2-I2):首次播种把组装历史持久进转录且恰好一次;
// 后续 run 由转录承载,不重复播种。
func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
sandbox := t.TempDir()
rc := &fakeRunCtx{
ctx: context.Background(), sandbox: sandbox,
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
history: []core.Message{
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "历史一"}}},
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "回复一"}}},
{ID: "m3", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "最新"}}},
},
}
var requests [][]agentllm.Message
base := scriptStream(textTurns("一答", "二答"), nil)
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
requests = append(requests, append([]agentllm.Message{}, msgs...))
return base(ctx, model, msgs, tools, opts)
}}
if err := r.Execute(rc); err != nil {
t.Fatalf("first Execute: %v", err)
}
// 第二跑:同一 rc(新 user 消息由 history 尾部提供)。
rc.history = append(rc.history, core.Message{ID: "m4", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "再来"}}})
if err := r.Execute(rc); err != nil {
t.Fatalf("second Execute: %v", err)
}
if len(requests) < 2 {
t.Fatalf("stream 调用数 = %d", len(requests))
}
for i, req := range requests {
count := 0
for _, m := range req {
if m.Content == "历史一" {
count++
}
}
if count != 1 {
t.Fatalf("第 %d 跑历史中「历史一」必须恰好一次,得到 %d", i+1, count)
}
}
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
if err != nil {
t.Fatal(err)
}
loaded, err := sess.Load()
if err != nil {
t.Fatal(err)
}
count := 0
for _, m := range loaded {
if m.Content == "历史一" {
count++
}
}
if count != 1 {
t.Fatalf("转录应恰好一次包含种子历史「历史一」,得到 %d:%#v", count, loaded)
}
}
// ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)----
// 端口注入的 [记忆] 块到达请求(迁移前经组装侧 InjectMemory 同文案形态)。
func TestDirectRunInjectsMemoryBlock(t *testing.T) {
var gotReq []agentllm.Message
base := scriptStream(textTurns("收到"), nil)
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
memEntries: []memEntry{
{typ: "fact", content: "项目用 Go 1.27"},
{typ: "preference", content: "喜欢简洁回复"},
},
}
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
gotReq = append([]agentllm.Message{}, msgs...)
return base(ctx, model, msgs, tools, opts)
}}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
var block string
for _, m := range gotReq {
if strings.Contains(m.Content, "[记忆]") {
block = m.Content
}
}
if block == "" {
t.Fatalf("记忆块缺失:%#v", gotReq)
}
// 形态对照(与 ctxasm.InjectMemory 同文):[记忆]\n- (typ) content,确定性排序。
if !strings.Contains(block, "- (fact) 项目用 Go 1.27") ||
!strings.Contains(block, "- (preference) 喜欢简洁回复") {
t.Fatalf("记忆块形态不对:%q", block)
}
if strings.Index(block, "(fact)") > strings.Index(block, "(preference)") {
t.Fatalf("排序不确定:%q", block)
}
}
+51 -5
View File
@@ -2,6 +2,8 @@ package runtime
import (
"context"
agentmemory "agent-native/runtime/memory"
"errors"
"fmt"
"strings"
@@ -39,6 +41,9 @@ type fakeRunCtx struct {
history []core.Message // 非 nil 时覆盖默认历史
bgID string
bgErr error
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
bgCalls int
}
@@ -58,6 +63,15 @@ func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error {
return nil
}
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
func (f *fakeRunCtx) AddUsage(u core.Usage) {
f.usageTotal.PromptTokens += u.PromptTokens
f.usageTotal.CompletionTokens += u.CompletionTokens
f.usageTotal.TotalTokens += u.TotalTokens
f.usageTotal.LastPromptTokens = u.LastPromptTokens
f.usageTotal.LastCompletionTokens = u.LastCompletionTokens
f.usageTotal.CacheReadTokens += u.CacheReadTokens
f.usageTotal.CacheWriteTokens += u.CacheWriteTokens
}
func (f *fakeRunCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
return core.HITLResponse{}, fmt.Errorf("not implemented")
}
@@ -94,6 +108,34 @@ func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID,
}
return f.bgID, nil
}
func (f *fakeRunCtx) ConversationSandbox() string {
if f.sandbox != "" {
return f.sandbox
}
return ""
}
// memEntry 是测试用记忆条目(避免 fake 引入库类型)。
type memEntry struct{ typ, content string }
type fakeMemStore struct{ entries []memEntry }
func (s fakeMemStore) Recall(_ context.Context, q agentmemory.RecallQuery) ([]agentmemory.Entry, error) {
out := make([]agentmemory.Entry, 0, len(s.entries))
for _, e := range s.entries {
out = append(out, agentmemory.Entry{Type: e.typ, Content: e.content})
}
return out, nil
}
func (s fakeMemStore) Append(_ context.Context, e agentmemory.Entry) error { return nil }
func (f *fakeRunCtx) StoreMemory() agentmemory.Store {
if f.memEntries == nil {
return nil
}
return fakeMemStore{entries: f.memEntries}
}
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
func (f *fakeRunCtx) Subtask() string { return "" }
@@ -784,7 +826,8 @@ func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
if calls != 2 {
t.Fatalf("stream calls = %d, want 2", calls)
}
var placeholder bool
// N6(N3 WP1):伪造正文不得作为 assistant 的话术回放;更正以 user
// 消息出现(不再改写 assistant)。
for _, m := range replay {
if m.Role != agentllm.RoleAssistant {
continue
@@ -792,12 +835,15 @@ func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) {
if strings.Contains(m.Content, "run_python") {
t.Fatalf("forged body was replayed: %q", m.Content)
}
if strings.Contains(m.Content, "已作废") {
placeholder = true
}
var correction bool
for _, m := range replay {
if m.Role == agentllm.RoleUser && strings.Contains(m.Content, "已作废") {
correction = true
}
}
if !placeholder {
t.Fatalf("correction replay missing placeholder, got %#v", replay)
if !correction {
t.Fatalf("correction replay missing user note, got %#v", replay)
}
texts := textParts(rc.assistant)
if len(texts) != 2 || !texts[0].Process || texts[1].Process {
+6 -5
View File
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
"agent-native/runtime/gates"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
@@ -218,11 +219,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
if p.Text == "答案是 42。" && !p.Process {
kept = true
}
if !p.Process && strings.Contains(p.Text, "验证器不可用") {
if !p.Process && strings.Contains(p.Text, "验证器未独立配置") {
notice = true
}
}
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") {
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") && !hasProcessNote(rc.assistant, "未独立配置") {
t.Fatalf("parts = %+v", texts)
}
})
@@ -230,11 +231,11 @@ func TestVerifierGarbageFailsClosed(t *testing.T) {
}
func TestParseVerdictToleratesFence(t *testing.T) {
got, err := parseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
got, err := gates.ParseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
if err != nil || got.Passed || got.Reason != "不够" {
t.Fatalf("verdict = %+v %v", got, err)
}
if _, err := parseVerdict(`{"reason":"no passed"}`); err == nil {
if _, err := gates.ParseVerdict(`{"reason":"no passed"}`); err == nil {
t.Fatal("missing passed should fail")
}
}
@@ -392,7 +393,7 @@ func TestFalseDeliverableFixtures(t *testing.T) {
}
for _, c := range doc.Cases {
t.Run(c.Name, func(t *testing.T) {
block, reason := deliverableGate(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
block, reason := deliverableGateOf(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
if !block || strings.TrimSpace(reason) == "" {
t.Fatalf("gate = %v %q", block, reason)
}
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"agent-system-backend/internal/docgen"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/wfcompile"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
@@ -214,7 +215,8 @@ func (m *MockRunner) Execute(rc RunCtx) error {
}
case strings.Contains(user, "#workflow"):
res, err := (WorkflowRunner{}).run(rc, workflow.Launch{Def: mockWorkflow()})
// Q3 WP2:演示工作流同走编译图(纯宿主步,不依赖模型后端)。
res, err := (CompiledWorkflowRunner{}).runLaunch(rc, workflow.Launch{Def: mockWorkflow()}, wfcompile.RunOptions{})
if err != nil {
if res.Summary != "" {
addText(res.Summary)
+35
View File
@@ -0,0 +1,35 @@
package runtime
import (
"context"
"testing"
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
)
// PermissionAskOf 只在宿主实现内核级询问时接线;否则返回 nil,
// 内核保持「ask 按拒绝处理」的旧行为。
func TestPermissionAskOfOptional(t *testing.T) {
type plain struct{ RunCtx }
if got := PermissionAskOf(plain{}); got != nil {
t.Fatal("plain rc returned a non-nil ask port, want nil")
}
}
func TestPermissionAskOfWired(t *testing.T) {
type asker struct{ RunCtx }
wired := PermissionAskOf(askStub{})
if wired == nil {
t.Fatal("wired rc returned nil")
}
if v := wired(context.Background(), agentllm.ToolCall{Name: "run_shell"}); !v.Allow {
t.Fatalf("verdict = %+v", v)
}
}
type askStub struct{ RunCtx }
func (askStub) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict {
return nruntime.AskVerdict{Allow: true}
}
+42
View File
@@ -0,0 +1,42 @@
package runtime
import (
"context"
"strings"
"testing"
core "agent-system-core"
)
// P4:计划提醒常开——没有任何计划/待办而任务变长时,给一次可忽略的轻提醒;
// 已有计划但停滞的旧提醒行为不变。
func TestPlanlessNudgeFiresOnce(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
}
quiet := textDeltaTurn("", toolCall("make_report", "{}"))
turns := [][]interface{}{}
_ = turns
st := &directRun{l: &LLMRunner{}, rc: rc, eff: core.EffectiveSettings{}}
var seen []string
emitReminder := func(turn int) []string {
// 模拟连续工具轮推进。
st.closedToolTurn = true
st.toolTurnsSince++
return st.reminders(turn)
}
for i := 0; i < 3; i++ {
seen = append(seen, emitReminder(i)...)
}
if len(seen) != 1 || !strings.Contains(seen[0], "update_plan") {
t.Fatalf("planless nudge should fire exactly once, got %v", seen)
}
for i := 0; i < 6; i++ {
seen = append(seen, emitReminder(i)...)
}
if len(seen) != 1 {
t.Fatalf("planless nudge must not repeat, got %v", seen)
}
_ = quiet
}
+18
View File
@@ -5,6 +5,9 @@ package runtime
import (
"context"
nruntime "agent-native/runtime"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
@@ -56,3 +59,18 @@ type Runner interface {
Name() string
Execute(rc RunCtx) error
}
// PermissionAskOf 把宿主的内核级权限询问(runmgr 的 HITL 确认卡)接成
// nruntime.Config.PermissionAsk 端口;宿主未实现时返回 nil,内核保持
// 「ask 按拒绝处理」的旧行为(N1 WP2 装配;直连/子代理/小队共用)。
// 入参为 any:直连/子代理传 RunCtx,小队装配传 squadadapter.Ctx——
// 判定只依赖可选的 PermissionAsk 方法,不要求实现整个 RunCtx。
func PermissionAskOf(rc any) func(context.Context, agentllm.ToolCall) nruntime.AskVerdict {
asker, ok := rc.(interface {
PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict
})
if !ok {
return nil
}
return asker.PermissionAsk
}
+5 -1
View File
@@ -53,7 +53,11 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
toolParts = append(toolParts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)}})
case "usage":
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
sink.AddUsage(core.Usage{PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens})
sink.AddUsage(core.Usage{
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
})
}
}
}

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