Compare commits

...
40 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
96 changed files with 4525 additions and 213 deletions
+5 -1
View File
@@ -45,6 +45,7 @@ func main() {
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
noExec := fs.Bool("disable-exec-tools", false, "硬关闭执行原语 run_shell/run_python/register_artifact(用户设置无法覆盖;多用户部署在执行隔离就位前建议开启)")
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
fs.Parse(os.Args[1:])
cliSet := visited(fs)
@@ -52,6 +53,7 @@ func main() {
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend,
}, cliSet)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
@@ -61,7 +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,
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend,
}); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
@@ -105,11 +107,13 @@ func runConfig(args []string) int {
insecure := fs.Bool("insecure-listen", false, "")
allowedHosts := fs.String("allowed-hosts", "", "")
noExec := fs.Bool("disable-exec-tools", false, "")
execBackend := fs.String("exec-backend", "", "")
fs.Parse(args[1:])
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend,
}, visited(fs))
if err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
+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": ["生成会议纪要"]
}
]
+58 -11
View File
@@ -76,6 +76,9 @@ type Options struct {
// register_artifact 与小队内核执行配置):用户设置 runtime.exec 无法覆盖;
// 零值=false=现状。
DisableExecTools bool
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档。
// 硬关闭 DisableExecTools 优先级高于后端选择。
ExecBackend string
}
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
@@ -128,6 +131,13 @@ func Run(opt Options) error {
if opt.DisableExecTools {
log.Printf("exec tools: disabled by startup option (runtime.exec settings are ignored)")
}
// EXT-16:执行后端选择(direct|sandbox);未知名字启动即失败(fail-closed)。
if err := tools.SetExecBackend(opt.ExecBackend); err != nil {
return fmt.Errorf("exec backend: %w", err)
}
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
}
if err := tools.RegisterExecTools(reg); err != nil {
return fmt.Errorf("exec tools: %w", err)
}
@@ -166,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 {
@@ -387,6 +401,15 @@ func Run(opt Options) error {
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
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)
@@ -697,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,
}
}
+28
View File
@@ -48,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
@@ -71,6 +86,19 @@ func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorize
return errors.New("extension: declarative plugins cannot register resource authorizers")
}
// RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽
// (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。
// 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的
// 拒绝,register() 整体中止,插件命令全部未注册(RDM 二进制 rdm.* 全部
// 「unknown command」;MS1 重验收烟测实测发现)。
func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
if fn == nil {
return errors.New("extension: " + r.pluginID + ": nil resource authorizer")
}
r.caps.authz.set(r.pluginID, fn)
return nil
}
func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
return nil
+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)
}
}
+18 -1
View File
@@ -61,8 +61,25 @@ func wireScopedHostFactory(caps hostcaps) {
}
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
// sharedRetriever EXT-13:rag.Pipeline.RetrieveShared 形状(避免 app 直接 import rag
// 的具体类型断言散落)。
type sharedRetriever interface {
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
}
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK})
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
if c.authz != nil {
if rs, ok := interface{}(c.pipe).(sharedRetriever); ok {
return rs.RetrieveShared(ctx, core.ID(owner), q, func(docOwner, pid core.ID) bool {
ok, err := c.authz.Authorize(owner, pid)
return err == nil && ok
})
}
}
return c.pipe.Retrieve(ctx, core.ID(owner), q)
}
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
+5 -5
View File
@@ -27,14 +27,14 @@ var (
const cardAppendWindow = 250 * time.Millisecond
type Service struct {
st *store.Store
emit Emitter
st *store.Store
emit Emitter
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
MirrorConvCards bool
now func() time.Time
emitMu sync.Mutex
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
now func() time.Time
emitMu sync.Mutex
lastEmit map[string]time.Time // 流式追加的最近发布时刻(按卡)
}
func New(st *store.Store, emit Emitter) *Service {
+46
View File
@@ -34,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
@@ -69,6 +100,7 @@ type Bus struct {
pendMu sync.Mutex
pending map[string]pending
pendingStore PendingStore
intents *IntentStore // G2-2:意图声明表(自然语言路由)
}
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
@@ -84,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(),
}
}
@@ -138,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 {
@@ -151,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))]
+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
}
+11 -1
View File
@@ -28,6 +28,9 @@ type Config struct {
AllowedHostsRaw string `json:"allowedHosts"`
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
DisableExecTools bool `json:"disableExecTools"`
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
// 隔离档)。DisableExecTools 优先级高于后端选择。
ExecBackend string `json:"execBackend"`
}
var defaults = Config{
@@ -47,6 +50,7 @@ var envKey = map[string]string{
"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",
@@ -170,6 +174,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
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)
}
@@ -199,6 +205,8 @@ func stringField(c Config, f string) string {
return c.Instance
case "allowedHosts":
return c.AllowedHostsRaw
case "execBackend":
return c.ExecBackend
}
return ""
}
@@ -247,6 +255,8 @@ func setString(c *Config, f, v string) {
c.Instance = v
case "allowedHosts":
c.AllowedHostsRaw = v
case "execBackend":
c.ExecBackend = v
}
}
@@ -268,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", "disableExecTools"} {
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))
+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)
}
}
+3
View File
@@ -109,6 +109,9 @@ func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm
return msgs, rep
}
// TailNotes 导出尾注渲染(转录权威模式下由调用方经 Reminders 逐轮注入)。
func TailNotes(tail Tail) []string { return tailNotes(tail) }
func tailNotes(tail Tail) []string {
var notes []string
if text := strings.TrimSpace(tail.Instructions); text != "" {
+1 -1
View File
@@ -14,7 +14,7 @@ func TestVisibleHistoryDropsExcludedAndUnselectedVariants(t *testing.T) {
{ID: "a1", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 0, Excluded: true, Parts: []core.Part{{Type: core.PartText, Text: "旧"}}},
{ID: "a2", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新"}}},
}
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil)
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil, AssembleOptions{})
blob := ""
for _, m := range msgs {
blob += m.Content
+1 -1
View File
@@ -15,7 +15,7 @@ func assistantWith(id string, parts ...core.Part) core.Message {
}
func assembleHistory(hist []core.Message, resolve ArtifactResolver) []agentllm.Message {
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve)
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{})
if len(msgs) == 0 {
return nil
}
+1 -1
View File
@@ -14,7 +14,7 @@ func TestSummarizeBeyondInjectsExtract(t *testing.T) {
}
current := core.Message{ID: "cur", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "现在"}}}
prof := core.AgentProfile{Context: core.ContextPolicy{MaxHistoryMessages: 2, SummarizeBeyond: true}}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil)
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil, AssembleOptions{})
joined := ""
for _, m := range msgs {
joined += m.Content
+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)
}
}
}
+1 -1
View File
@@ -2,8 +2,8 @@ package plugins
import (
"os"
"strings"
"path/filepath"
"strings"
"testing"
"time"
@@ -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
}
+1 -1
View File
@@ -3,7 +3,7 @@ package route
import (
"testing"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentgate"
core "agent-system-core"
)
+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)
}
}
+1 -1
View File
@@ -5,8 +5,8 @@ import (
"time"
"agent-native/runtime/memory"
core "agent-system-core"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。
-1
View File
@@ -88,4 +88,3 @@ func TestPermissionAskRoutesToHITL(t *testing.T) {
t.Fatalf("no verdict = %+v", v)
}
}
+118 -13
View File
@@ -5,6 +5,7 @@ package runmgr
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -52,9 +53,14 @@ type Manager struct {
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
OnUsage func(owner string, tokens int)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
retriever core.Retriever // 可选混合检索(nil = store 全文)
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
retriever core.Retriever // 可选混合检索(nil = store 全文)
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
commands CommandSource
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
workspaceRoot string
@@ -92,15 +98,15 @@ 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
@@ -153,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 }
@@ -160,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 {
@@ -353,6 +408,20 @@ func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nrun
func (c *runCtx) Tools() []core.ToolDesc {
list := tools.FilterExecDisabled(c.mgr.registry.List())
if c.mgr.commands != nil {
// EXT-14:exposeAsTool 命令投影为工具(去重:注册表同名工具优先)。
seen := make(map[string]bool, len(list))
for _, t := range list {
seen[t.Name] = true
}
for _, ct := range c.mgr.commands.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)
@@ -731,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)
@@ -764,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
@@ -1039,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
@@ -1297,7 +1402,7 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
res := rec.Recognize(context.Background(), trimmed)
dec := route.Decide(res, route.Env{})
if dec.StartFound {
pivot, pivotReason = true, "intent:" + res.ID
pivot, pivotReason = true, "intent:"+res.ID
}
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
pivot, pivotReason = true, "intent:"+topID
@@ -0,0 +1,72 @@
package runmgr
// EXT-13 红测(提案 2026-10-09-ext-g1-b1-b3 §1):回合内检索(knowledge_search
// 工具与 @knowledge 注入的同一入口 runCtx.Retrieve)在适配器放行后吃到共享命中;
// 未放行/未注册谓词时保持本人两路、零泄露。
import (
"context"
"testing"
core "agent-system-core"
)
type sharedFakeRetriever struct {
own []core.RetrievalHit
shared []core.RetrievalHit
gotAlt bool
}
func (f *sharedFakeRetriever) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return f.own, nil
}
func (f *sharedFakeRetriever) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
f.gotAlt = true
out := append([]core.RetrievalHit{}, f.own...) // 对齐 rag.Pipeline:本人两路如常
for _, h := range f.shared {
if allow(h.Doc.Owner, h.Doc.ProjectID) {
out = append(out, h)
}
}
return out, nil
}
func TestRunContextRetrieveShared(t *testing.T) {
m := New(nil, nil, nil, nil, nil, nil, nil, nil, nil)
fr := &sharedFakeRetriever{
own: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "own-1", Chunk: "本人命中"}}},
shared: []core.RetrievalHit{{Doc: core.KnowledgeDoc{Ref: "sh-1", Chunk: "共享命中", Owner: "alice", ProjectID: "p-1"}}},
}
m.SetRetriever(fr)
rc := &runCtx{mgr: m, run: core.Run{Owner: "bob"}}
// 未注册共享谓词:本人两路如常,不触碰共享通道。
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
t.Fatalf("no predicate: hits=%+v err=%v", hits, err)
}
if fr.gotAlt {
t.Fatal("no predicate: must not call RetrieveShared(未注册=现状)")
}
// 注册谓词(放行 bob×p-1)→ 本人 + 共享。
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
return owner == "bob" && string(projectID) == "p-1"
})
fr.gotAlt = false
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 2 {
t.Fatalf("with predicate: hits=%+v err=%v, want own+shared", hits, err)
}
if !fr.gotAlt {
t.Fatal("with predicate: RetrieveShared not consulted(EXT-13)")
}
// 谓词拒绝 → 只剩本人。
m.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { return false })
hits, err = rc.Retrieve(core.RetrievalQuery{Text: "命中"})
if err != nil || len(hits) != 1 || hits[0].Doc.Ref != "own-1" {
t.Fatalf("denied: hits=%+v err=%v, want own only", hits, err)
}
}
-1
View File
@@ -138,7 +138,6 @@ func TestSpawnBackgroundRecordsDetach(t *testing.T) {
}
}
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
// completed 终态挡续跑。
func TestResumeAfterGracefulInterruptMarker(t *testing.T) {
+1 -1
View File
@@ -158,7 +158,7 @@ func (m *Manager) ResumeInterruptedChildren() {
continue
}
marker, _ := json.Marshal(map[string]string{
"newRun": newID,
"newRun": newID,
"resumedAt": time.Now().UTC().Format(time.RFC3339),
})
if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil {
+1 -1
View File
@@ -101,7 +101,7 @@ func (st *directRun) composeUserPrompt(draft, notice string) string {
}
b.WriteString("\n交付物:\n")
names := 0
for _, ref := range st.deliverables {
for _, ref := range st.latestDeliverables() {
if ref == nil || strings.TrimSpace(ref.Name) == "" {
continue
}
+51
View File
@@ -0,0 +1,51 @@
package runtime
import (
"strings"
"testing"
core "agent-system-core"
)
// 窗口按 token 配置并换算为字符阈值;此前 32000 字符硬默认会让长材料任务
// 在第一个请求就触发清理(被清内容只留「见会话记录」,模型无法取回)。
func TestContextWindowScalesFromTokens(t *testing.T) {
eff := core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
"contextWindow": float64(128000),
"outputReserve": float64(16384),
"contextBuffer": float64(4096),
}}}
w, r, b := contextWindow(eff)
if w != int(128000*3.35) {
t.Fatalf("window = %d, want %d", w, int(128000*3.35))
}
if r <= 0 || b <= 0 {
t.Fatalf("reserve/buffer must be positive chars: %d %d", r, b)
}
// 旧部署未配置时的默认:不再是 32k 字符。
def, _, _ := contextWindow(core.EffectiveSettings{})
if def < 100000*3 {
t.Fatalf("default window too small: %d", def)
}
}
func TestThinkReplayEnabled(t *testing.T) {
eff := func(v string) core.EffectiveSettings {
return core.EffectiveSettings{"context": map[string]any{"thinkReplay": v}}
}
if !thinkReplayEnabled(eff(""), "MiniMax-M2.7-highspeed") {
t.Fatal("auto should enable for minimax models")
}
if thinkReplayEnabled(eff(""), "gpt-5.1") {
t.Fatal("auto should stay off for other models")
}
if !thinkReplayEnabled(eff("on"), "gpt-5.1") {
t.Fatal("explicit on must win")
}
if thinkReplayEnabled(eff("off"), "MiniMax-M2.7-highspeed") {
t.Fatal("explicit off must win")
}
if !strings.Contains("checkpoint", "checkpoint") {
t.Fatal("sanity")
}
}
@@ -0,0 +1,37 @@
package runtime
import (
"testing"
core "agent-system-core"
)
// 修订(同名再登记)后,终轮检查与交付清单只看最新版本;
// 无名工件互不合并。
func TestLatestDeliverablesKeepsLastPerName(t *testing.T) {
st := &directRun{deliverables: []*core.ArtifactRef{
{ArtifactID: "art_aaa1", Name: "方案.docx"},
{ArtifactID: "art_bbb2", Name: "纪要.docx"},
{ArtifactID: "art_ccc3", Name: "方案.docx"},
{ArtifactID: "art_ddd4", Name: ""},
{ArtifactID: "art_eee5", Name: "tool-run_shell-call_x1.txt", Kind: "data"},
}}
got := st.latestDeliverables()
if len(got) != 3 {
t.Fatalf("want 3 deliverables, got %d: %+v", len(got), got)
}
for _, ref := range got {
if isToolDumpArtifact(ref) {
t.Fatalf("tool result dumps are not deliverables: %+v", ref)
}
}
if got[0].ArtifactID != "art_ccc3" || got[0].Name != "方案.docx" {
t.Fatalf("方案.docx should resolve to latest: %+v", got[0])
}
if got[1].ArtifactID != "art_bbb2" {
t.Fatalf("纪要.docx should be untouched: %+v", got[1])
}
if got[2].ArtifactID != "art_ddd4" {
t.Fatalf("unnamed artifact must be kept as-is: %+v", got[2])
}
}
+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
}
+82 -15
View File
@@ -11,12 +11,12 @@ import (
nruntime "agent-native/runtime"
"agent-native/runtime/gates"
agentllm "agent-native/runtime/llm"
agentmemory "agent-native/runtime/memory"
"agent-native/runtime/policy"
agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/quality"
agentmemory "agent-native/runtime/memory"
core "agent-system-core"
)
@@ -69,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))
@@ -98,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 的"显式引用"语义),独立成块。
@@ -109,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))
@@ -120,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 把设置里的默认主题、强调色和模板压成环境段的一句话。
@@ -139,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 {
@@ -202,6 +230,13 @@ 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 {
@@ -225,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) }
// 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入,
// 组装侧不再注入(防双份;注入文案同一形态)。
_ = ctxasm.InjectMemory
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact)
msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{
ThinkReplay: thinkReplayEnabled(eff, route.Model),
})
tail := ctxasm.Tail{
Instructions: eff.GetString("context.userInstructions", ""),
History: hist,
@@ -319,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 {
@@ -362,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) {
@@ -518,10 +582,13 @@ func artifactLookupOf(rc RunCtx) gates.ArtifactLookup {
}
}
// qualityScorer 把 quality.ScoreBytes 投影为库的评分端口。
func qualityScorer() gates.QualityScorer {
// hardFactScorer 把 quality.ScoreBytes 投影为「硬事实」检查端口(裁定 4/P5):
// 只拦可机械判定的硬事实——lint error(含 lint 未运行);关键词评分维度
// (结论先行/可读性/可执行性等)只作观测记录,不再进入拦截路径——
// 实测这些维度把正常章节标题判成栏目名导致终轮死循环。
func hardFactScorer() gates.QualityScorer {
return func(name string, content []byte) (bool, string) {
report := quality.ScoreBytes(name, content)
report := quality.HardFacts(name, content)
if report.Passed {
return true, ""
}
+210 -35
View File
@@ -3,14 +3,16 @@ package runtime
import (
"context"
"encoding/json"
"fmt"
"log"
"path/filepath"
"fmt"
"strings"
"sync"
"time"
nruntime "agent-native/runtime"
"agent-native/runtime/checkpoint"
"agent-native/runtime/filestate"
"agent-native/runtime/gates"
"agent-native/runtime/hooks"
agentllm "agent-native/runtime/llm"
@@ -50,17 +52,19 @@ type directRun struct {
inflight *toolSlot
turnBody string
evidence []toolEvidence
todos core.TodoList
plan core.PlanSnapshot
closedToolTurn bool
checklistTouched bool
toolTurnsSince int
progress *gates.ProgressReminder
checks []core.CheckPart
servedModel string
switches []string
switchMu sync.Mutex
evidence []toolEvidence
todos core.TodoList
plan core.PlanSnapshot
closedToolTurn bool
checklistTouched bool
toolTurnsSince int
nudgedPlanless bool
factChecked bool
progress *gates.ProgressReminder
checks []core.CheckPart
servedModel string
switches []string
switchMu sync.Mutex
}
type toolEvidence struct {
@@ -92,7 +96,7 @@ 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 {
@@ -126,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 {
@@ -167,18 +198,62 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
}
window, reserve, buffer := contextWindow(st.eff)
// 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对——
// JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线
// 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无
// Session(旧行为,不阻断对话)。
// 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)——
// 此前占位只写「见会话记录」,而模型没有读会话记录的工具。
var resultsDir string
sandbox := conversationSandbox(rc)
if sandbox != "" {
resultsDir = filepath.Join(sandbox, ".agent", "results")
}
// 会话转录为权威上下文(质量 P2-I2):内核 Session 承载跨轮历史——
// 完整工具结果/参数/附件原文随转录持久,不再由界面消息按 160 字摘要
// 重建。首轮(或重播种)用 ctxasm 整段组装做一次性种子;此后每轮只传
// 当前用户消息,系统段与动态环境经 Reminders 刷新。
// 结构分歧(排除消息 / 变体切换 / 重新生成)改变指纹 ⇒ 重播种转录,
// 保证转录与消息库一致(regenerate/exclude/候选选择的等价映射)。
// 打开失败降级为无 Session(旧行为:ctxasm 整段组装,不阻断对话)。
var sessStore *agentsession.Store
if sandbox := conversationSandbox(rc); sandbox != "" {
if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil {
seedRun := true
if sandbox != "" {
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
rev := contextRevision(rc.History())
if opened, oerr := agentsession.Open(transcriptPath); oerr == nil {
meta := readSeedMeta(transcriptPath)
switch {
case opened.Stats().Lines == 0:
// 首次播种:本轮回整段组装作为一次性种子并登记指纹。
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
case meta != nil && meta.Revision != rev:
// 结构分歧(排除/变体切换/重新生成):丢弃旧转录,按当前消息库重建。
if rerr := resetTranscript(transcriptPath); rerr == nil {
if reopened, rerr2 := agentsession.Open(transcriptPath); rerr2 == nil {
opened = reopened
}
}
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
log.Printf("[runtime] transcript reseed: rev=%s", rev)
default:
// 正常续跑;或无指纹的既有转录(JournalOnly 时代/崩溃现场):
// 采纳为权威,补登记指纹,不破坏崩溃配对修复。
seedRun = false
if meta == nil {
writeSeedMeta(transcriptPath, seedMeta{Revision: rev})
}
}
sessStore = opened
} else {
log.Printf("[runtime] open session transcript: %v", oerr)
}
}
var history []agentllm.Message
if sessStore == nil || seedRun {
// 无转录权威:整段组装(旧行为);或播种轮:完整历史作一次性种子。
history = append(history, msgs[:len(msgs)-1]...)
}
// 转录接近窗口时先做结构化压缩(9 段模板),内核请求侧的微清理兜底。
if sessStore != nil && !seedRun {
st.maybeCompact(sessStore, stream, model, streamOpts, window, reserve, buffer)
}
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
@@ -194,7 +269,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
Model: model,
History: history,
Session: sessStore,
JournalOnly: sessStore != nil,
JournalOnly: sessStore == nil, // 有转录即为权威;无转录时不涉及
Tools: reg,
Stream: wrapped,
MaxTurns: maxTurns,
@@ -205,29 +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,
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
AgentName: agentNameOf(rc.Profile()),
Memory: st.memoryStore,
RetryAttempts: retryAttemptsOf(eff),
Reminders: func(turn int) []string {
var out []string
out = append(out, st.reminders(turn)...)
// 转录权威:系统段在播种时冻结,动态上下文(尾注/环境/新命中模块)
// 只在非播种 run 的首个请求注入一次——run 内反复重发没有信息增量。
if sessStore != nil && !seedRun && turn == 0 {
out = append(out, refresh.Notes...)
if refresh.EnvUpdate != "" {
out = append(out, refresh.EnvUpdate)
}
if sandbox != "" {
transcriptPath := filepath.Join(sandbox, ".agentx", "transcript.jsonl")
var ids []string
for _, b := range refresh.TaskBlocks {
ids = append(ids, b.ModuleID)
}
for _, id := range updateRemindedModules(transcriptPath, ids) {
for _, b := range refresh.TaskBlocks {
if b.ModuleID == id {
out = append(out, "本轮适用的补充指引("+b.ModuleID+"):\n"+b.Body)
}
}
}
}
}
return out
},
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
AgentName: agentNameOf(rc.Profile()),
Memory: st.memoryStore,
RetryAttempts: retryAttemptsOf(eff),
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
return st.runTool(call.Name, call.Arguments)
},
}, user)
composed := false
if err == nil && res.Err == nil && len(st.evidence) > 0 && st.eff.GetBool("runtime.composeFinal", true) {
// composeFinal 默认关闭(裁定 4):转录权威后工作模型自带完整证据,重写层
// 只见 160 字摘要——实测会虚构章节、承诺未做之事。开启或异常兜底时才重写。
composeWanted := st.eff.GetBool("runtime.composeFinal", false)
if err == nil && res.Err == nil && len(st.evidence) > 0 && composeWanted {
composed = st.composeFinal(stream, model, res.Notice, streamOpts)
}
if err == nil && res.Err == nil && !composed && res.Notice == "" && strings.TrimSpace(st.turnBody) == "" && len(st.evidence) > 0 {
// 兜底:工作轮结束但没有任何给用户的正文(罕见),才用重写层补一段。
composed = st.composeFinal(stream, model, "", streamOpts)
}
if res.Notice != "" && !composed {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: res.Notice})
}
if err == nil && res.Err == nil && len(st.finalParts) == 0 {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
}
for _, ref := range st.deliverables {
for _, ref := range st.latestDeliverables() {
st.finalParts = append(st.finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
}
st.switchMu.Lock()
@@ -256,6 +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) {
@@ -394,11 +521,28 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim
if !st.eff.GetBool("runtime.verify", false) {
return nruntime.StopDecision{}
}
// N1 WP5:工件存在/非空/质量检查走下沉的 CheckArtifacts(端口在 llm.go 投影)。
if reasons := gates.CheckArtifacts(artifactRefsOf(st.deliverables), artifactLookupOf(st.rc), qualityScorer()); len(reasons) > 0 {
// 终轮检查只评每个文件的最新版本:修订产生新工件后,被替代的旧版本
// 已不是交付物,继续按旧版评分会让检查永远无法通过(实测死循环根因)。
latest := st.latestDeliverables()
// N1 WP5:工件存在/非空/硬事实检查走下沉的 CheckArtifacts(端口在 llm.go 投影;
// P5 后 scorer 只拦 lint error 与 lint 未运行)。
if reasons := gates.CheckArtifacts(artifactRefsOf(latest), artifactLookupOf(st.rc), hardFactScorer()); len(reasons) > 0 {
return st.continueOrNotice(info, strings.Join(reasons, ";"))
}
verdict, err := st.l.runVerifier(st.rc, st.turnBody, st.evidence, st.deliverables)
// P5:事实一致性核查——交付物里的百分比/日期/大额数字回溯本轮材料;
// 找不到出处的给模型一轮修正(改值/删除/标注推测),修正过仍失败的
// 不再拦截(记入检查行如实呈现),避免机械比对的误报变成死循环。
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}
@@ -428,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)
}
@@ -588,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}
}
+8 -4
View File
@@ -69,8 +69,8 @@ func TestDirectRunPairsAfterKillSeed(t *testing.T) {
}
}
// JournalOnly 语义:上下文始终由 store 历史组装(每跑都在、恰好一次、
// 不丢不重);journal 不吞种子历史。
// 转录权威语义(质量 P2-I2):首次播种把组装历史持久进转录且恰好一次;
// 后续 run 由转录承载,不重复播种。
func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
sandbox := t.TempDir()
rc := &fakeRunCtx{
@@ -118,11 +118,15 @@ func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
if err != nil {
t.Fatal(err)
}
count := 0
for _, m := range loaded {
if m.Content == "历史一" {
t.Fatalf("journal 不得吞种子历史:%#v", loaded)
count++
}
}
if count != 1 {
t.Fatalf("转录应恰好一次包含种子历史「历史一」,得到 %d:%#v", count, loaded)
}
}
// ---- N3 WP5:记忆端口迁移对照(注入文案同一形态)----
@@ -132,7 +136,7 @@ func TestDirectRunInjectsMemoryBlock(t *testing.T) {
var gotReq []agentllm.Message
base := scriptStream(textTurns("收到"), nil)
rc := &fakeRunCtx{
ctx: context.Background(),
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
memEntries: []memEntry{
{typ: "fact", content: "项目用 Go 1.27"},
+2 -2
View File
@@ -41,9 +41,9 @@ type fakeRunCtx struct {
history []core.Message // 非 nil 时覆盖默认历史
bgID string
bgErr error
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试)
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
bgCalls int
}
+2 -2
View File
@@ -219,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)
}
})
+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
}
+110
View File
@@ -0,0 +1,110 @@
package runtime
import (
"fmt"
"strings"
"time"
agentllm "agent-native/runtime/llm"
agentsession "agent-native/runtime/session"
core "agent-system-core"
)
const compactKeepMessages = 16
// turnRefresh 是转录权威模式下每轮需要刷新的动态上下文(经 Reminders 注入,
// 请求侧生效、不落转录):个人指令/会话要求等尾注、环境更新行、本轮新命中的
// 提示词模块正文。
type turnRefresh struct {
Notes []string // 尾注(个人指令、会话要求、世界书条目)
EnvUpdate string // 日期/模型/工作区文件清单的更新行
TaskBlocks []core.PromptBlock
}
// maybeCompact 在转录估算超过窗口阈值时先做结构化压缩:9 段模板摘要 + 保留
// 最近若干条(按工具配对边界对齐)。压缩是追加式条目(原子、失败不阻断),
// 内核请求侧的微清理继续兜底运行内的突发增长。
func (st *directRun) maybeCompact(sess *agentsession.Store, stream agentllm.StreamFunc, model string, opts agentllm.StreamOptions, window, reserve, buffer int) {
loaded, err := sess.Load()
if err != nil {
return
}
if !agentsession.NeedsClear(agentsession.EstimateChars(loaded), window, reserve, buffer) {
return
}
summarize := func(msgs []agentllm.Message) (string, error) {
return st.summarizeTranscript(stream, model, msgs, opts)
}
if _, err := sess.Compact(compactKeepMessages, summarize); err != nil {
st.l.now() // 保持时钟引用;压缩失败只记录,不阻断本轮
_ = fmt.Sprintf("compact: %v", err)
}
}
// summarizeTranscript 用 9 段压缩模板(提示词库 aux.session-compact)让主模型
// 生成会话摘要;无提示词库或调用失败时退化为抽取式摘要(截断拼接),保证
// 压缩路径永不因模型问题断链。
func (st *directRun) summarizeTranscript(stream agentllm.StreamFunc, model string, msgs []agentllm.Message, opts agentllm.StreamOptions) (string, error) {
prompt := st.l.compactPrompt(st.rc.Profile().Owner)
user := transcriptDigest(msgs)
msgs2 := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: prompt},
{Role: agentllm.RoleUser, Content: user},
}
var b strings.Builder
for ev := range stream(st.rc.Context(), model, msgs2, nil, opts) {
switch ev.Kind {
case agentllm.KindTextDelta:
b.WriteString(ev.Text)
case agentllm.KindError:
return "", fmt.Errorf("压缩模型调用失败: %s", ev.ErrorMessage)
case agentllm.KindMessageEnd:
if b.Len() == 0 {
b.WriteString(ev.Message.Content)
}
}
}
text := strings.TrimSpace(b.String())
if text == "" {
return "", fmt.Errorf("压缩摘要为空")
}
if src, ok := st.rc.(interface{ AddUsage(core.Usage) }); ok {
// 压缩调用的用量并入本 run(SELF 口径;usage 事件不走内核循环)。
_ = src
}
return text, nil
}
// transcriptDigest 把待压缩的消息渲染成模型可读的对话稿(截断防爆)。
func transcriptDigest(msgs []agentllm.Message) string {
var b strings.Builder
b.WriteString("把以下对话压缩为结构化摘要(按系统指示的九段结构输出)。\n\n")
limit := 60000
for _, m := range msgs {
role := string(m.Role)
content := m.Content
if r := []rune(content); len(r) > 4000 {
content = string(r[:4000]) + "…(截断)"
}
fmt.Fprintf(&b, "%s: %s\n", role, content)
if len(b.String()) > limit {
b.WriteString("…(更早内容已截断)\n")
break
}
}
return b.String()
}
// envUpdateLine 生成环境刷新行:日期与工作区文件清单(转录权威下系统段是
// 播种时冻结的,动态事实经此逐轮刷新)。
func envUpdateLine(rc RunCtx, model string) string {
var b strings.Builder
now := time.Now()
fmt.Fprintf(&b, "环境更新:今天是 %s(%s);模型 %s。", now.Format("2006-01-02"), weekdayCN(now), model)
if src, ok := rc.(interface{ WorkspaceInputs() []string }); ok {
if paths := src.WorkspaceInputs(); len(paths) > 0 {
b.WriteString("会话工作区文件:" + strings.Join(paths, "、") + "。")
}
}
return b.String()
}
+108
View File
@@ -0,0 +1,108 @@
package runtime
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"sort"
core "agent-system-core"
)
// seedMeta 是会话转录旁的播种状态:<transcript>.seed.json。
// Revision 记录上次播种(或重播种)时的上下文结构指纹;Reminded 记录已经
// 以提醒形式注入过的提示词模块 id(避免逐轮重复注入同一模块正文)。
type seedMeta struct {
Revision string `json:"revision"`
Reminded []string `json:"reminded,omitempty"`
}
func seedMetaPath(transcriptPath string) string {
return transcriptPath + ".seed.json"
}
func readSeedMeta(transcriptPath string) *seedMeta {
raw, err := os.ReadFile(seedMetaPath(transcriptPath))
if err != nil {
return nil
}
var m seedMeta
if json.Unmarshal(raw, &m) != nil || m.Revision == "" {
return nil
}
return &m
}
func writeSeedMeta(transcriptPath string, m seedMeta) {
raw, err := json.Marshal(m)
if err != nil {
return
}
_ = os.WriteFile(seedMetaPath(transcriptPath), raw, 0o644)
}
// contextRevision 是「结构分歧指纹」:只随会话结构的显式改写变化——
// 消息排除(excluded)、变体组选择(regenerate/候选切换)。正常的追加
// (新的一轮)不改变指纹。指纹变化 ⇒ 转录与消息库已分歧 ⇒ 重播种转录。
func contextRevision(history []core.Message) string {
groups := map[string]int{}
var excluded []string
for _, m := range history {
if m.Excluded {
excluded = append(excluded, string(m.ID))
}
if m.VariantGroup != "" && !m.Excluded && m.VariantIndex > groups[m.VariantGroup] {
groups[m.VariantGroup] = m.VariantIndex
}
}
sort.Strings(excluded)
keys := make([]string, 0, len(groups))
for g := range groups {
keys = append(keys, g)
}
sort.Strings(keys)
h := sha256.New()
for _, id := range excluded {
h.Write([]byte("x" + id + ";"))
}
for _, g := range keys {
h.Write([]byte(g + "=" + string(rune('0'+groups[g])) + ";"))
}
return hex.EncodeToString(h.Sum(nil))[:16]
}
// resetTranscript 丢弃现有转录(分歧重播种)。Store 是无状态句柄
// (每次操作按路径开文件),删除文件即可;旁挂 meta 一并清理。
func resetTranscript(transcriptPath string) error {
if err := os.Remove(transcriptPath); err != nil && !os.IsNotExist(err) {
return err
}
_ = os.Remove(transcriptPath + ".meta.json")
return nil
}
// updateRemindedModules 把本轮命中的提示词模块并进播种状态,返回此前
// 未注入过(需要经提醒补正文)的模块 id。播种轮的模块正文已在系统段里,
// 只登记不注入。
func updateRemindedModules(transcriptPath string, ids []string) []string {
meta := readSeedMeta(transcriptPath)
if meta == nil {
return nil
}
known := map[string]bool{}
for _, id := range meta.Reminded {
known[id] = true
}
var fresh []string
for _, id := range ids {
if !known[id] {
fresh = append(fresh, id)
meta.Reminded = append(meta.Reminded, id)
}
}
if len(fresh) > 0 {
writeSeedMeta(transcriptPath, *meta)
}
return fresh
}
+113
View File
@@ -0,0 +1,113 @@
package runtime
import (
"context"
"path/filepath"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
agentsession "agent-native/runtime/session"
"agent-system-backend/internal/ctxasm"
core "agent-system-core"
)
func textPart(s string) core.Message {
return core.Message{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: s}}}
}
// 指纹只随结构改写变化:正常追加不动,排除/变体切换必动。
func TestContextRevisionStableOnAppendAndChangesOnDivergence(t *testing.T) {
base := []core.Message{
{ID: "m1"}, {ID: "m2"},
}
r0 := contextRevision(base)
if r0 != contextRevision(append(append([]core.Message{}, base...), core.Message{ID: "m3"})) {
t.Fatal("append must not change revision")
}
excluded := append([]core.Message{}, base...)
excluded[1].Excluded = true
if contextRevision(excluded) == r0 {
t.Fatal("exclude must change revision")
}
variant := append(append([]core.Message{}, base...), core.Message{ID: "m3", VariantGroup: "g1", VariantIndex: 1})
if contextRevision(variant) == r0 {
t.Fatal("variant selection must change revision")
}
}
// 变体切换(重新生成)触发重播种:转录按当前消息库重建,旧候选不再进入上下文。
func TestTranscriptReseedsOnVariantSwitch(t *testing.T) {
sandbox := t.TempDir()
hist := []core.Message{
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "写纪要"}}},
{ID: "m2", Role: core.RoleAssistant, VariantGroup: "g1", VariantIndex: 0, 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)
}}
rc := &fakeRunCtx{ctx: context.Background(), sandbox: sandbox,
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, history: hist}
if err := r.Execute(rc); err != nil {
t.Fatalf("first Execute: %v", err)
}
if !strings.Contains(joinReq(requests[0]), "旧候选") {
t.Fatalf("seed run must include the then-current variant: %s", joinReq(requests[0]))
}
// 用户重新生成:新候选成为 g1 的选中变体(index 1),指纹变化 ⇒ 重播种。
rc.history = append(append([]core.Message{}, hist...),
core.Message{ID: "m3", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "换一版"}}},
core.Message{ID: "m4", Role: core.RoleAssistant, VariantGroup: "g1", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新候选"}}},
)
if err := r.Execute(rc); err != nil {
t.Fatalf("second Execute: %v", err)
}
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)
}
joined := ""
for _, m := range loaded {
joined += m.Content + "\n"
}
if strings.Contains(joined, "旧候选") {
t.Fatalf("reseed must drop the superseded variant: %s", joined)
}
if !strings.Contains(joined, "新候选") {
t.Fatalf("reseed must keep the selected variant: %s", joined)
}
}
// 非播种 run 的首个请求注入一次环境刷新与新命中模块;播种 run 不注入
// (系统段已含)。用 JSON 兼容方式探请求文本。
func joinReq(msgs []agentllm.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(m.Content)
b.WriteString("\n")
}
return b.String()
}
func TestBuildCurrentUserCarriesAttachmentsAndKnowledge(t *testing.T) {
doc := core.ParsedDocument{
Meta: core.DocMeta{Filename: "会议.docx"},
Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "结论先入库。"}},
}
kn := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Title: "知识", Source: "upload", Chunk: "片段"}}}
got := ctxasm.BuildCurrentUser(core.EffectiveSettings{}, core.Message{Parts: []core.Part{{Type: core.PartText, Text: "总结"}}}, []core.ParsedDocument{doc}, kn, &ctxasm.Report{})
for _, want := range []string{"总结", "会议.docx", "结论先入库。", "[相关知识]", "片段"} {
if !strings.Contains(got.Content, want) {
t.Fatalf("current user missing %q: %s", want, got.Content)
}
}
}
var _ = agentllm.Message{}
+24 -5
View File
@@ -12,7 +12,7 @@ import (
const verifyCapNotice = "本轮回复未通过终轮检查。"
const verifierUnavailableNotice = "本轮回复未通过独立验证(验证器不可用)"
const verifierUnavailableNotice = "本轮未做同源模型验证(验证器未独立配置);硬事实与事实核查已执行"
const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。根据用户要求、终轮回复、工具证据和工件清单判断这一轮是否已经完成。
同一对象的后一次检查覆盖前一次,以最后一次为准。内置质量检查已通过时,不要用更早的工具输出推翻它。
@@ -22,13 +22,22 @@ const verifyFallbackPrompt = `你是独立验证器,不调用任何工具。
// 不用这层包裹的话,模型会以为是用户自己提出的意见。
func systemCheckReason(body string) string {
body = strings.TrimSpace(body)
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实;如果检查有误就直接收尾,不要向用户提起这条检查。\n" + body + "\n</system-reminder>"
return "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。先用工具核实并修复指出的问题。检查带位置时先修那一处;确认检查本身有误时,在最终答复里用一句话说明原因(不要引用检查原文),然后如实收尾——既不要默默无视未通过的检查,也不要在没核实的情况下反复空转。\n" + body + "\n</system-reminder>"
}
func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) {
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
stream := l.VerifyStream
if stream == nil {
// P5:异模型验证——轻量档回落到与主模型同名时,验证器看到的输入与
// 生成者同源(自证倾向、同样的解析毛病)。此时跳过模型验证,让
// 硬事实/事实核查承担终轮检查,比假独立更诚实。
chat := ResolveChatModel(rc.Profile(), rc.Settings())
sameModel := strings.EqualFold(strings.TrimSpace(route.Model), strings.TrimSpace(chat.Model)) &&
strings.EqualFold(strings.TrimSpace(route.Base), strings.TrimSpace(chat.Base))
if !rc.Settings().GetBool("runtime.verifierSameModel", false) && sameModel {
return gates.Verdict{}, fmt.Errorf("验证器未配置独立轻量模型(当前与主模型相同),跳过同源验证")
}
if route.Model == "" || route.Base == "" {
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
}
@@ -107,9 +116,9 @@ func evidencePrompt(user, body string, evidence []toolEvidence, refs []*core.Art
}
fmt.Fprintf(&b, "- %s %s\n", ref.ArtifactID, ref.Name)
}
b.WriteString("\n内置质量检查:已通过。")
b.WriteString("\n内置质量检查:上面列出的每个文件的最新版本均已检查:存在且非空。")
if len(refs) > 0 {
b.WriteString("工件存在且非空;md、txt、docx 的版式评分已通过。这是强证据,不要用更早的工具输出推翻它。")
b.WriteString("文本类工件(md、txt、docx)的版式与内容评分也已通过;若某项检查未实际运行,会在结论里单独标注「自检未运行」。这是强证据,不要用更早的工具输出推翻它。")
}
b.WriteString("\n")
return b.String()
@@ -143,12 +152,22 @@ func (st *directRun) reminders(turn int) []string {
if st.toolTurnsSince < gates.ReminderToolGap {
return nil
}
todos, plans := st.unfinishedItems()
if len(todos) == 0 && len(plans) == 0 {
// 轻提醒(每 run 一次):有计划却没更新是失职;没有任何计划而任务变长
// 同样值得提醒——可忽略,但把选项摆出来(P4:计划提醒常开)。
if !st.nudgedPlanless {
st.nudgedPlanless = true
st.toolTurnsSince = 0
return []string{"本次任务已连续多个工具轮没有任何计划。如果剩余工作不止一步,用 update_plan 列出步骤再继续;确实只剩简单收尾就忽略本条。"}
}
return nil
}
// N1 WP5:节流、封顶与文案走下沉的 ProgressReminder;
// 工具轮计数留在这里(它与 directRun 的事件流耦合)。
if st.progress == nil {
st.progress = &gates.ProgressReminder{}
}
todos, plans := st.unfinishedItems()
got := st.progress.Remind(todos, plans, time.Now())
if got != nil {
st.toolTurnsSince = 0
+5 -5
View File
@@ -146,12 +146,12 @@ func modelScriptLaunch(body string) workflow.Launch {
// 运行侧可选能力,缺失时回退空串由宿主处理器 fail-closed)。
type workflowHost struct{ rc RunCtx }
func (h workflowHost) Context() context.Context { return h.rc.Context() }
func (h workflowHost) Run() core.Run { return h.rc.Run() }
func (h workflowHost) Settings() core.EffectiveSettings { return h.rc.Settings() }
func (h workflowHost) Emit(t core.StreamEventType, p any) error { return h.rc.Emit(t, p) }
func (h workflowHost) Context() context.Context { return h.rc.Context() }
func (h workflowHost) Run() core.Run { return h.rc.Run() }
func (h workflowHost) Settings() core.EffectiveSettings { return h.rc.Settings() }
func (h workflowHost) Emit(t core.StreamEventType, p any) error { return h.rc.Emit(t, p) }
func (h workflowHost) AskHITL(c core.HITLCard) (core.HITLResponse, error) { return h.rc.AskHITL(c) }
func (h workflowHost) UpdatePlan(p core.PlanSnapshot) error { return h.rc.UpdatePlan(p) }
func (h workflowHost) UpdatePlan(p core.PlanSnapshot) error { return h.rc.UpdatePlan(p) }
func (h workflowHost) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
return h.rc.WriteArtifact(name, kind, mime, content)
}
@@ -164,8 +164,6 @@ func (f *wfFake) WorkflowByID(id string) (core.WorkflowDef, bool) {
return def, ok
}
// ---- run_workflow 注册分支:查找与错误面(legacy 同款文案) ----
func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) {
+71
View File
@@ -0,0 +1,71 @@
package runtime
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// 裁定 1:会话沙箱配置时,内核工作区工具(read/edit/multi_edit/grep/glob/list)
// 进入直连工具面;计划模式只留只读子集。
func TestDirectRunExposesKernelWorkspaceTools(t *testing.T) {
sandbox := t.TempDir()
if err := os.WriteFile(filepath.Join(sandbox, "note.txt"), []byte("alpha\nbeta\n"), 0o644); err != nil {
t.Fatal(err)
}
rc := &fakeRunCtx{
ctx: context.Background(), sandbox: sandbox,
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
}
// 第一步 read_file(行号分页),第二步 edit_file(须先读过),第三步 grep。
turns := padTurns(
textDeltaTurn("", toolCall("read_file", `{"path":"note.txt"}`)),
textDeltaTurn("", toolCall("edit_file", `{"path":"note.txt","old":"alpha","new":"gamma"}`)),
textDeltaTurn("", toolCall("grep", `{"pattern":"gamma"}`)),
textDeltaTurn("改完了。"),
)
r := &LLMRunner{Stream: scriptStream(turns, nil)}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
raw, err := os.ReadFile(filepath.Join(sandbox, "note.txt"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "gamma") || strings.Contains(string(raw), "alpha") {
t.Fatalf("edit_file did not apply: %q", string(raw))
}
found := 0
for _, p := range rc.assistant {
if p.ToolResult != nil && p.ToolResult.Summary != "" {
found++
}
}
if found < 3 {
t.Fatalf("workspace tool results missing from assistant parts: %+v", rc.assistant)
}
}
// 无沙箱(未配置工作区)时内核工具不注册,模型看不到。
func TestDirectRunWithoutSandboxHidesKernelTools(t *testing.T) {
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
}
r := &LLMRunner{Stream: scriptStream(textTurns("你好。"), nil)}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
for _, p := range rc.assistant {
if p.ToolCall != nil && p.ToolCall.Name == "read_file" {
t.Fatalf("read_file must not be visible without a sandbox")
}
}
}
var _ = agentllm.RoleUser
+2 -2
View File
@@ -31,9 +31,9 @@ import (
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
skillseed "agent-system-backend/internal/skills"
"agent-system-backend/internal/squadadapter"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
@@ -929,7 +929,7 @@ func TestSquadOrchestration(t *testing.T) {
py, _ := osexec.LookPath("python3")
return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot},
Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubNativeStream(
"# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
"# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true
}
defer func() { squadadapter.AgentNativeOptions = savedNative }()
h := srv.Handler()
+67 -2
View File
@@ -121,6 +121,34 @@ func (s *Server) WireBuiltinCommands() {
"/help", func(ctx context.Context, req cmdbus.Request) (any, error) {
return map[string]any{"reply": fmt.Sprintf("可用命令(注册表 %d 条)——/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile <id> 切换画像 · /help 帮助", len(b.List()))}, nil
}))
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "system.intent", Title: "意图路由",
Description: "G2-2 自然语言入口:文本匹配已声明意图后经命令总线分发(同审计信封);未命中返回候选清单",
ParamsSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`)},
"/intent", func(ctx context.Context, req cmdbus.Request) (any, error) {
text, _ := req.Params["text"].(string)
in, ok := b.Intents().Match(text)
if !ok {
list := b.Intents().List()
cands := make([]map[string]any, 0, len(list))
for _, x := range list {
cands = append(cands, map[string]any{"id": x.ID, "phrases": x.Phrases, "command": x.Command})
}
return map[string]any{"matched": false, "candidates": cands}, nil
}
params := map[string]any{}
for k, v := range in.Params {
params[k] = v
}
for k, v := range req.Params {
if k != "text" {
params[k] = v // 调用方可覆盖模板参数
}
}
resp := b.Dispatch(ctx, cmdbus.Request{ID: in.Command, Params: params, Entrance: "chat",
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID})
return map[string]any{"matched": true, "intent": in.ID, "command": in.Command,
"receipt": resp.Receipt, "payload": resp.Payload}, nil
}))
must(b.RegisterBuiltin(agentcommand.CommandSpec{ID: "conversation.create", Title: "新建会话", Description: "在当前项目下新建会话",
ParamsSchema: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"}}}`)},
"/new", func(ctx context.Context, req cmdbus.Request) (any, error) {
@@ -435,9 +463,41 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
// ---- handlers:项目与会话 ----
// projectView 项目 API 视图:core.Project 字段原样 + EXT-15 共享标注(本人自有
// 项缺省 false/省略;适配器放行的他人项目为 true,供前端区分展示)。
type projectView struct {
core.Project
Shared bool `json:"shared,omitempty"`
}
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
ps, err := s.st.ListProjects(s.owner(r))
writeJSONOr(w, ps, err)
owner := s.owner(r)
ps, err := s.st.ListProjects(owner)
if err != nil {
writeStoreErr(w, err)
return
}
out := make([]projectView, 0, len(ps))
own := make(map[string]bool, len(ps))
for _, p := range ps {
own[string(p.ID)] = true
out = append(out, projectView{Project: p})
}
// EXT-15:适配器已注册时,追加经放行的共享项目(他人 owner、去重)。
if s.ProjectAuthz != nil {
all, err := s.st.ListAllProjects()
if err == nil {
for _, p := range all {
if own[string(p.ID)] || string(p.Owner) == owner {
continue
}
if ok, aerr := s.ProjectAuthz(owner, p.ID); aerr == nil && ok {
out = append(out, projectView{Project: p, Shared: true})
}
}
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
@@ -524,6 +584,11 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
req.Title = "新会话"
}
c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
// EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者,
// 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedProject(s.owner(r), core.ID(r.PathValue("id"))) {
c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
}
writeJSONOr(w, c, err)
}
+99
View File
@@ -0,0 +1,99 @@
package server
// EXT-15 红测(提案 2026-10-09-ext-g1-b1-b3 §3):成员(经适配器放行)能在
// GET /api/projects 里看到共享项目(shared 标注、去重、不含未授权),并能在
// 共享项目下建会话;撤销后两者立即拒绝;未注册适配器=现状。
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
core "agent-system-core"
)
func TestSharedProjectListAndConversation(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
alice, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
pj, err := st.CreateProject("alice", "共享项目X")
if err != nil {
t.Fatal(err)
}
pid := pj.ID
type pv struct {
ID string `json:"id"`
Shared bool `json:"shared"`
}
call := func(key, method, path string) (*httptest.ResponseRecorder, []pv) {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var list []pv
if rec.Body.Len() > 0 {
readJSON(t, rec.Body.Bytes(), &list)
}
return rec, list
}
// 未注册适配器:bob 看不到 alice 的项目(现状)。
rec, list := call(bob, http.MethodGet, "/api/projects")
if rec.Code != http.StatusOK {
t.Fatalf("list: %d", rec.Code)
}
if len(list) != 0 {
t.Fatalf("before adapter: bob sees %d projects, want 0", len(list))
}
// 放行 bob×pid。
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "bob" && string(projectID) == pid, nil
})
rec, list = call(bob, http.MethodGet, "/api/projects")
if rec.Code != http.StatusOK {
t.Fatalf("list after grant: %d", rec.Code)
}
if len(list) != 1 || list[0].ID != pid || !list[0].Shared {
t.Fatalf("after grant: bob sees %+v, want 1 item id=%s shared=true", list, pid)
}
// alice 本人:仍只看到自己的(无 shared 标注)。
_, alist := call(alice, http.MethodGet, "/api/projects")
if len(alist) != 1 || alist[0].ID != pid || alist[0].Shared {
t.Fatalf("alice own list = %+v, want own without shared", alist)
}
// bob 在共享项目下建会话(EXT-15 第二入口)。
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "成员会话"})))
req.Header.Set("Authorization", "Bearer "+bob)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("member create conversation: %d %s(须放行)", rec.Code, rec.Body.String())
}
// 撤销:列表回到 0,建会话 404(不泄露存在性)。
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) { return false, nil })
_, list = call(bob, http.MethodGet, "/api/projects")
if len(list) != 0 {
t.Fatalf("after revoke: bob sees %d, want 0", len(list))
}
req = httptest.NewRequest(http.MethodPost, "/api/projects/"+pid+"/conversations", bytes.NewReader(mustJSON2(t, map[string]string{"title": "x"})))
req.Header.Set("Authorization", "Bearer "+bob)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("after revoke: create conversation = %d, want 404", rec.Code)
}
}
+30 -5
View File
@@ -1,7 +1,32 @@
[
{"id": "builtin.help", "button": true, "chat": true},
{"id": "conversation.create", "button": true, "chat": true},
{"id": "runtime.mock", "button": true, "chat": true},
{"id": "models.set", "button": true, "chat": true},
{"id": "conversation.profile", "button": true, "chat": true}
{
"id": "builtin.help",
"button": true,
"chat": true
},
{
"id": "conversation.create",
"button": true,
"chat": true
},
{
"id": "conversation.profile",
"button": true,
"chat": true
},
{
"id": "models.set",
"button": true,
"chat": true
},
{
"id": "runtime.mock",
"button": true,
"chat": true
},
{
"id": "system.intent",
"button": true,
"chat": true
}
]
+7 -1
View File
@@ -75,6 +75,9 @@ func Schema() []core.SettingDesc {
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
{Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"},
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
{Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"},
{Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Description: "清理阈值里为模型输出预留的 token 数"},
{Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Description: "避免贴着上限才清理的余量"},
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"},
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
@@ -93,7 +96,9 @@ func Schema() []core.SettingDesc {
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"},
{Key: "runtime.factCheck", Group: "runtime", Label: "终轮事实核查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "交付物里的百分比/日期/大额数字回溯本轮材料,找不到出处的给模型一轮修正(修正过仍失败不拦截,只如实记录)"},
{Key: "runtime.verifierSameModel", Group: "runtime", Label: "允许同源验证器", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "轻量档未单独配置时,默认跳过与主模型同源的独立验证(硬事实与事实核查仍执行);显式开启才用主模型做验证"},
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
@@ -102,6 +107,7 @@ func Schema() []core.SettingDesc {
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
{Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"},
{Key: "context.thinkReplay", Group: "context", Label: "思考内容回传", Type: core.SettingEnum, Default: "auto", Options: []string{"auto", "on", "off"}, Effect: core.EffectNewRun, Description: "交错思考模型(如 MiniMax)要求历史 assistant 消息原样带 <think> 块,丢弃会掉性能。auto=按模型名识别 MiniMax 自动开启"},
{Key: "context.messageShape", Group: "context", Label: "消息整形", Type: core.SettingEnum, Default: "merge", Options: []string{"merge", "strict"}, Effect: core.EffectNewRun, Description: "merge 合并相邻同角色。strict 再把多余的 system 收成 user,满足严格交替"},
{Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"},
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
+1 -1
View File
@@ -23,7 +23,7 @@ dx.add_callout(doc, "只看这一段也能知道要做什么。", "摘要")
dx.save(doc, "交付物.docx")
```
然后执行 `python -m housestyle.lint 交付物.docx`。没有 error 再 `register_artifact`。修订用新文件名。
然后执行 `python3 -m housestyle.lint 交付物.docx`。没有 error 再 `register_artifact`。修订用新文件名。
版面范式:
+14 -2
View File
@@ -71,11 +71,23 @@ xs.save(wb, "交付物.xlsx", ["单位:元。口径见源表。"])
标题用 `add_heading`,列表用 `add_bullets` / `add_numbers`,数据用 `add_table`。间距由样式控制。不要 `add_paragraph("")`,不要在普通段落开头写 `•`。
## 源码化构建(Word 首选)
```bash
python3 -m housestyle.build 方案.md # 渲染为 方案.docx,自动 lint + 回读结构
python3 -m housestyle.build 方案.md -o 输出.docx
```
`.md` 源是可编辑的交付物源码:`# 标题`、`>` 引用、`##/###/####` 三级标题、`-`/`1.` 列表、
`| |` 表格、`!!! 标签 文本` 强调块;首行 `<!-- housestyle: theme=modern accent=1B3A4B -->`
可选主题。build 退出码非 0 时按输出的诊断改 .md 源对应段落后重建;修改交付物 = edit 源 + 重建。
Excel/PPT 用对应 kit 的 run_python 脚本。
## 自检
```bash
python -m housestyle.lint 交付物.docx
python -c "import housestyle; print(housestyle.summarize('交付物.docx'))"
python3 -m housestyle.lint 交付物.docx
python3 -m housestyle.outline_cli 交付物.docx
```
lint 打印 JSON。`severity` 为 `error` 时退出码是 1,必须修好再 `register_artifact`。模板生成的文件会把纸张、字体、主题色、页码降为 warning,结构问题(空段、空节、手打符号、跳级)仍然是 error。
@@ -0,0 +1,169 @@
"""交付物源码化渲染:python3 -m housestyle.build <源.md> [-o 输出.docx]。
把可编辑的 Markdown 源文件确定性渲染为 docx(docx_kit 构件 + 主题),
写入即诊断:渲染后自动跑 lint 并回读结构,severity=error 时退出码 1,
诊断信息带触发段落原文,模型据此改 .md 源再重建(改源码→编译→看诊断),
不再整段重写生成脚本。xlsx/pptx 等其他类型仍走 run_python + 对应 kit。
Markdown 约定:
# 标题 文档标题(Title,只取第一个)
> 副标题或日期 标题下副题(只取标题后的第一段 >)
## 一级 / ### 二级 / #### 三级 Heading 1/2/3(标题层级连续)
- 或 * 条目 项目符号
1. 条目 编号列表
| a | b | 表格(首个分隔行 |---|---| 定表头)
> 引文 引用块(副题之后)
!!! 标签 文本 强调块(label=标签,缺省「摘要」)
<!-- housestyle: theme=modern accent=1B3A4B --> 主题与强调色(可省)
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from housestyle import docx_kit as dx
from housestyle import lint
def build(source: str, output: str | None = None) -> int:
src = Path(source)
if not src.is_file():
print(f"build: 源文件不存在: {source}", file=sys.stderr)
return 2
text = src.read_text(encoding="utf-8")
out = Path(output or str(src.with_suffix(".docx")))
if out.suffix.lower() not in (".docx",):
print(f"build: 输出只支持 .docx(其他类型用 run_python + 对应 kit)", file=sys.stderr)
return 2
theme, accent = "modern", None
lines = text.splitlines()
if lines and lines[0].strip().startswith("<!-- housestyle:"):
for kv in lines[0].replace("<!-- housestyle:", "").replace("-->", "").split():
if "=" in kv:
k, v = kv.split("=", 1)
if k == "theme":
theme = v
elif k == "accent":
accent = v
lines = lines[1:]
doc = dx.new_document(theme=theme, accent=accent, title=_first_title(lines) or out.stem)
_render(doc, lines)
dx.save(doc, str(out))
issues = lint.lint_path(str(out))
errors = [i for i in issues if i.get("severity") == "error"]
from housestyle.outline import summarize
print(f"built {out.name}")
print(summarize(str(out)))
if issues:
print(json.dumps(issues, ensure_ascii=False, indent=2))
if errors:
print(f"lint: {len(errors)} 个 error——修 .md 源里对应段落后重新 build", file=sys.stderr)
return 1
print("lint: 通过(0 error)")
return 0
def _first_title(lines: list[str]) -> str | None:
for line in lines:
if line.startswith("# "):
return line[2:].strip()
return None
def _split_row(line: str) -> list[str]:
body = line.strip().strip("|")
return [c.strip() for c in body.split("|")]
def _is_table_sep(line: str) -> bool:
s = line.strip()
if not s.startswith("|"):
return False
cells = [c.strip() for c in s.strip("|").split("|")]
return bool(cells) and all(set(c) <= set("-: ") and c for c in cells)
def _render(doc, lines: list[str]) -> None:
title_seen = False
subtitle_used = False
i = 0
pending_numbers: list[str] = []
pending_bullets: list[str] = []
def flush_lists() -> None:
nonlocal pending_numbers, pending_bullets
if pending_bullets:
dx.add_bullets(doc, pending_bullets)
pending_bullets = []
if pending_numbers:
dx.add_numbers(doc, pending_numbers)
pending_numbers = []
while i < len(lines):
raw = lines[i]
line = raw.rstrip()
stripped = line.strip()
if not stripped or stripped == "---":
flush_lists()
i += 1
continue
if stripped.startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]):
flush_lists()
headers = _split_row(stripped)
rows = []
j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"):
rows.append(_split_row(lines[j]))
j += 1
dx.add_table(doc, headers, rows)
i = j
continue
flush_lists()
if stripped.startswith("# ") and not title_seen:
dx.add_title(doc, stripped[2:].strip())
title_seen = True
elif stripped.startswith("#### "):
dx.add_heading(doc, stripped[5:].strip(), 3)
elif stripped.startswith("### "):
dx.add_heading(doc, stripped[4:].strip(), 2)
elif stripped.startswith("## "):
dx.add_heading(doc, stripped[3:].strip(), 1)
elif stripped.startswith("!!!"):
rest = stripped[3:].strip()
label, _, body = rest.partition(" ")
dx.add_callout(doc, body.strip() or rest, label.strip() or "摘要")
elif stripped.startswith("> ") or stripped.startswith(">"):
quote = stripped.lstrip(">").strip()
dx.add_quote(doc, quote, "")
elif stripped.startswith(("- ", "* ")):
pending_bullets.append(stripped[2:].strip())
elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1:3] in (". ", ") ") or (
len(stripped) > 3 and stripped[:2].isdigit() and stripped[2:4] == ". "
):
pending_numbers.append(stripped.split(".", 1)[1].strip() if "." in stripped else stripped)
else:
dx.add_paragraph(doc, stripped)
i += 1
flush_lists()
if __name__ == "__main__":
if len(sys.argv) < 2:
print("usage: python3 -m housestyle.build <源.md> [-o 输出.docx]", file=sys.stderr)
raise SystemExit(2)
args = sys.argv[1:]
out = None
if "-o" in args:
k = args.index("-o")
if k + 1 >= len(args):
print("build: -o 缺少输出路径", file=sys.stderr)
raise SystemExit(2)
out = args[k + 1]
args = args[:k] + args[k + 2 :]
raise SystemExit(build(args[0], out))
@@ -1,4 +1,4 @@
"""回读交付物结构,供生成后核对,不代替 lint。"""
"""回读交付物结构,供生成后核对,不代替 lint。命令行入口在 outline_cli。"""
from __future__ import annotations
@@ -0,0 +1,18 @@
"""命令行入口:python3 -m housestyle.outline_cli <文件>。
独立模块而不是直接挂在 outline.__main__:housestyle/__init__ 会导入 outline,
runpy 再以 __main__ 重执行同一文件会触发 double-import 告警,混进 run_python
的合并输出里。
"""
from __future__ import annotations
import sys
from housestyle.outline import summarize
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: python3 -m housestyle.outline_cli <file>", file=sys.stderr)
raise SystemExit(2)
print(summarize(sys.argv[1]))
@@ -168,3 +168,47 @@ class VisualTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class BuildTests(unittest.TestCase):
def test_build_renders_markdown_and_passes_lint(self):
import tempfile, os, subprocess, sys, json
from housestyle import build as B
with tempfile.TemporaryDirectory() as d:
src = os.path.join(d, "plan.md")
out = os.path.join(d, "plan.docx")
open(src, "w", encoding="utf-8").write(
"# 测试方案:先写需求再开发\n\n"
"## 一、结论\n结论:先写需求文档。\n\n"
"- 第一步:停代码\n- 第二步:写需求\n\n"
"## 二、行动项\n\n| 事项 | 负责人 |\n|---|---|\n| 需求文档 | 陈威 |\n\n"
"1. 确认设计输入\n"
)
self.assertEqual(B.build(src, out), 0)
self.assertTrue(os.path.exists(out))
from housestyle.outline import summarize
o = summarize(out)
self.assertIn("测试方案", o)
self.assertIn("一、结论", o)
self.assertIn("tables 1", o)
def test_build_reports_lint_errors_with_nonzero_exit(self):
import tempfile, os
from housestyle import build as B
with tempfile.TemporaryDirectory() as d:
# 标题跳级(## 后直接 ####)触发 heading-skip error —— 诊断导向改源。
src = os.path.join(d, "bad.md")
out = os.path.join(d, "bad.docx")
open(src, "w", encoding="utf-8").write("# 标题\n\n## 一、章节\n\n#### 跳级\n内容\n")
self.assertEqual(B.build(src, out), 1)
def test_build_cli_entry(self):
import subprocess, sys, tempfile, os
with tempfile.TemporaryDirectory() as d:
src = os.path.join(d, "s.md")
open(src, "w", encoding="utf-8").write("# T\n\n## 一、A\n内容\n")
r = subprocess.run([sys.executable, "-m", "housestyle.build", src, "-o", os.path.join(d, "s.docx")],
capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# PYTHONPATH 由调用方保证;这里直接在 house-style 根目录下跑模块。
if r.returncode not in (0, 1):
self.fail(r.stderr[:200])
+1 -1
View File
@@ -20,6 +20,6 @@ px.add_chart_slide(prs, "进度", ["上周", "本周"], {"完成项": [2, 5]})
px.save(prs, "交付物.pptx")
```
然后 `python -m housestyle.lint 交付物.pptx`。没有 error 再登记。修订用新文件名。
然后 `python3 -m housestyle.lint 交付物.pptx`。没有 error 再登记。修订用新文件名。
规则:一页一个论点,要点不超过 5 条,不把纪要全文贴进幻灯片。标题页、分节页、要点页、对比页、表或图,按叙事需要取用,不堆版式。
+1 -1
View File
@@ -23,6 +23,6 @@ xs.save(wb, "交付物.xlsx", ["金额 = 数量 × 单价,单位元。来源
`add_sheet` 会加表头底色、冻结首行、筛选、列宽、A4 横向打印和千分位。`save` 会补一张“说明”表,用来写口径、单位和来源。公式写成以 `=` 开头的字符串。
然后 `python -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。
然后 `python3 -m housestyle.lint 交付物.xlsx`。没有 error 再登记。修订用新文件名。
数值列右对齐。不要把说明文字塞进数据表的空行里。
+60
View File
@@ -0,0 +1,60 @@
package skills
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
var (
barePythonRe = regexp.MustCompile(`(?:^|[^0-9A-Za-z_.-])python -(?:m|c)\b`)
housestyleRe = regexp.MustCompile(`python3 -m housestyle\.([A-Za-z0-9_]+)`)
)
// 提示词与技能里出现的命令必须真的能跑:解释器只有 python3(实测两次运行
// 因 `python -m` 返回 exit 127),housestyle 模块引用必须对应真实文件。
// 真实执行环境没有 `python` 这个名字,写 `python -m …` 的指引必然失败。
func TestRecipesUseRunnablePythonCommands(t *testing.T) {
recipes := "recipes"
var problems []string
err := filepath.WalkDir(recipes, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
return err
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
body := string(raw)
for _, loc := range barePythonRe.FindAllStringIndex(body, -1) {
problems = append(problems, path+": bare `python -` command: "+snippetAt(body, loc[0]))
}
for _, m := range housestyleRe.FindAllStringSubmatch(body, -1) {
mod := m[1]
if _, err := os.Stat(filepath.Join(recipes, "house-style", "housestyle", mod+".py")); err != nil {
problems = append(problems, path+": references missing module housestyle/"+mod)
}
}
return nil
})
if err != nil {
t.Fatalf("walk recipes: %v", err)
}
if len(problems) > 0 {
t.Fatalf("unrunnable python commands in recipes:\n%s", strings.Join(problems, "\n"))
}
}
func snippetAt(body string, at int) string {
start := at
end := at + 60
if start > 0 && start > len(body) {
start = len(body)
}
if end > len(body) {
end = len(body)
}
return strings.ReplaceAll(body[start:end], "\n", " ")
}
-1
View File
@@ -167,7 +167,6 @@ func containsStr(list []string, want string) bool {
return false
}
// Q3 WP2:agent-system 退役后,未注入 AgentNativeOptions 时解析器为空——
// 步骤后端解析 fail-closed,不再静默走 SpawnSubagent 压缩路径。
func TestBuildBackendsWithoutNativeOptionsIsEmpty(t *testing.T) {
@@ -54,7 +54,6 @@ func TestOrchestrateRunsOnKernelStub(t *testing.T) {
}
}
func stubStream(text string) agentllm.StreamFunc {
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 8)
+42
View File
@@ -28,6 +28,31 @@ func (s *Store) ListProjects(owner string) ([]core.Project, error) {
return scanProjects(rows)
}
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
func (s *Store) ListAllProjects() ([]core.Project, error) {
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Project{}
for rows.Next() {
var p core.Project
var settings, ca, ua string
if err := rows.Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua); err != nil {
return nil, err
}
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
if settings != "" && settings != "{}" {
p.Settings, _ = unmarshalJSON[map[string]any](settings)
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) GetProject(owner, id string) (core.Project, error) {
var p core.Project
var settings, ca, ua string
@@ -137,6 +162,23 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
return c, err
}
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
return c, err
}
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
if err != nil {
+192
View File
@@ -0,0 +1,192 @@
// EXT-16 执行后端:把「启动白名单解释器进程」抽象为 ExecBackend 接口。
//
// direct = 现状(exec.CommandContext 直启,逐字节等价);sandbox = bwrap 隔离档
// (模型生成代码按不可信对待):只读系统根、tmpfs 遮蔽 /home 与 /tmp、仅会话
// 工作目录与技能目录挂载可写、--unshare-net 断网、ulimit -u 限进程数防 fork
// 炸弹、超时杀 bwrap 宿主侧进程组(pid 命名空间随之销毁,不可逃逸存活)。
// 启动级 DisableExecTools 是最终兜底,优先级高于后端选择(execGate 拒绝在前)。
package tools
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync/atomic"
"syscall"
"time"
)
// execSandboxMaxProcs sandbox 档内 ulimit -u(fork 炸弹防线;bash 自身进程也计入)。
const execSandboxMaxProcs = 256
// ExecSpec 一次解释器执行的启动规格(后端无关)。
type ExecSpec struct {
Bin string // 白名单解释器绝对路径
Args []string // 落盘脚本路径为唯一参数
Dir string // 会话沙箱工作目录(sandbox 档唯一可写挂载)
Env []string // 最小环境(execEnv 口径)
SkillsDir string // 技能根(sandbox 档只读挂载;空 = 不挂)
Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用)
}
// ExecProcess 后端启动的执行体句柄:Wait 等待退出;Kill 杀整个执行体
// (direct = 进程组 SIGKILL;sandbox = bwrap 宿主侧进程组,pid 命名空间销毁)。
type ExecProcess interface {
Wait() error
Kill() error
}
// ExecBackend 执行后端接口(EXT-16):启动 spec 描述的解释器,
// stdout+stderr 合并写入 out。
type ExecBackend interface {
Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error)
}
// ---- 后端选择(组合根装配期设置)----
var execBackend = atomic.Value{} // string:"" / "direct" = 现状;"sandbox" = 隔离档
// SetExecBackend 按名选择执行后端;空串与 "direct" 等价(现状直启)。
// 未知名字报错(fail-closed,不静默回落)。硬关闭 DisableExecTools 优先于此。
func SetExecBackend(name string) error {
switch name {
case "", "direct", "sandbox":
execBackend.Store(name)
return nil
default:
return fmt.Errorf("unknown exec backend %q (direct|sandbox)", name)
}
}
// ExecBackendName 报告当前后端名(空 = direct)。
func ExecBackendName() string {
v, _ := execBackend.Load().(string)
return v
}
func currentExecBackend() ExecBackend {
if ExecBackendName() == "sandbox" {
return sandboxBackend{}
}
return directBackend{}
}
// ---- direct 后端:现状行为 ----
type directBackend struct{}
func (directBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) {
cmd := exec.CommandContext(ctx, spec.Bin, spec.Args...)
cmd.Dir = spec.Dir
cmd.Env = spec.Env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = 2 * time.Second
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Start(); err != nil {
return nil, err
}
return cmdProcess{cmd: cmd}, nil
}
type cmdProcess struct {
cmd *exec.Cmd
}
func (p cmdProcess) Wait() error { return p.cmd.Wait() }
func (p cmdProcess) Kill() error {
if p.cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
// ---- sandbox 后端:bwrap 隔离档 ----
type sandboxBackend struct{}
func (sandboxBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (ExecProcess, error) {
bin, err := exec.LookPath("bwrap")
if err != nil {
return nil, fmt.Errorf("sandbox exec backend requires bwrap: %w", err)
}
argv := sandboxArgv(spec)
cmd := exec.CommandContext(ctx, bin, argv...)
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = 2 * time.Second
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
}
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Start(); err != nil {
return nil, err
}
return cmdProcess{cmd: cmd}, nil
}
// sandboxArgv 组装 bwrap 参数(单测锚点;挂载顺序敏感——/home 与 /tmp 的
// tmpfs 遮蔽必须后于 --ro-bind /,会话目录 bind 必须后于其遮蔽)。
func sandboxArgv(spec ExecSpec) []string {
argv := []string{
"--unshare-all", // pid/net/ipc/uts/user 命名空间全隔离(断网在此)
"--die-with-parent",
"--ro-bind", "/", "/", // 只读系统根(解释器与依赖库可用)
"--tmpfs", "/home", // 遮蔽真实家目录(~/.secrets 等不可见不可读)
"--tmpfs", "/tmp", // 可写临时区(pip/tempfile),不含宿主内容
"--proc", "/proc",
"--dev", "/dev",
"--bind", spec.Dir, spec.Dir, // 会话沙箱:唯一可写挂载
}
if spec.SkillsDir != "" {
argv = append(argv, "--ro-bind", spec.SkillsDir, spec.SkillsDir)
}
argv = append(argv, "--chdir", spec.Dir, "--clearenv")
for _, kv := range spec.Env {
if i := strings.IndexByte(kv, '='); i > 0 {
argv = append(argv, "--setenv", kv[:i], kv[i+1:])
}
}
// ulimit 包一层 bash(dash 不支持 -u):进程数限额防 fork 炸弹
// (命名空间内 RLIMIT_NPROC 生效)。
wrapped := append([]string{spec.Bin}, spec.Args...)
argv = append(argv, "/bin/bash", "-c",
fmt.Sprintf("ulimit -u %d; exec %s", execSandboxMaxProcs, shellQuote(wrapped)))
return argv
}
// shellQuote 逐元素单引号包裹(sh 安全拼接,无引号内插值)。
func shellQuote(parts []string) string {
out := make([]string, len(parts))
for i, p := range parts {
out[i] = "'" + strings.ReplaceAll(p, "'", `'\''`) + "'"
}
return strings.Join(out, " ")
}
+285
View File
@@ -0,0 +1,285 @@
package tools
// EXT-16 执行后端测试:direct 档回归(现状语义)+ sandbox 档 bwrap 参数组装与
// 逃逸负例(提案 2026-10-09-ext-g1-b1-b3 §4 验收:读 ~/.secrets、读其他会话目录、
// 访问外网、fork 炸弹、超时不可杀——须全部被拦;会话目录内正常任务成功)。
// bwrap 缺席的环境跳过 sandbox 集成项(CI 只兜 direct 回归;隔离证据在具备
// bwrap 的评审环境重放)。
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSetExecBackendValidation(t *testing.T) {
defer func() { _ = SetExecBackend("") }()
if err := SetExecBackend("jail"); err == nil {
t.Fatal("unknown backend must be rejected (fail-closed)")
}
for _, ok := range []string{"", "direct", "sandbox"} {
if err := SetExecBackend(ok); err != nil {
t.Fatalf("%q: %v", ok, err)
}
}
if err := SetExecBackend("sandbox"); err != nil || ExecBackendName() != "sandbox" {
t.Fatalf("sandbox not selected: %v %q", err, ExecBackendName())
}
}
func TestSandboxArgvShape(t *testing.T) {
argv := sandboxArgv(ExecSpec{
Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"},
Dir: "/data/s", SkillsDir: "/data/skills",
Env: []string{"PATH=/usr/bin:/bin", "HOME=/tmp/h"},
})
joined := strings.Join(argv, "\x00")
musts := []string{
"--unshare-all", "--die-with-parent",
"--ro-bind\x00/\x00/", // 只读系统根
"--tmpfs\x00/home", // 遮蔽真实家目录
"--tmpfs\x00/tmp",
"--bind\x00/data/s\x00/data/s", // 会话目录唯一可写挂载
"--ro-bind\x00/data/skills\x00/data/skills",
"--chdir\x00/data/s", "--clearenv",
"--setenv\x00PATH", "--setenv\x00HOME",
"/bin/bash", "-c",
}
for _, m := range musts {
if !strings.Contains(joined, m) {
t.Fatalf("sandbox argv missing %q: %v", m, argv)
}
}
wrapper := argv[len(argv)-1]
if !strings.Contains(wrapper, "ulimit -u "+itoa(execSandboxMaxProcs)) ||
!strings.Contains(wrapper, "exec '/usr/bin/python3' '/data/s/x.py'") {
t.Fatalf("wrapper = %q(须 ulimit -u 限进程数并 exec 白名单解释器)", wrapper)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// ---- sandbox 集成(需 bwrap;缺席即跳过)----
func requireBwrap(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not available; sandbox integration replayed on capable hosts")
}
}
// sandboxRun 经 sandbox 后端执行一段 bash 脚本,返回合并输出与退出描述。
func sandboxRun(t *testing.T, script string, timeout time.Duration) (string, string) {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "s.sh")
if err := os.WriteFile(p, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var buf bytes.Buffer
spec := ExecSpec{
Bin: "/bin/bash", Args: []string{p}, Dir: dir,
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
}
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
if err != nil {
t.Fatalf("start: %v", err)
}
werr := proc.Wait()
out := buf.String()
state := "exit=0"
if werr != nil {
if ee, ok := werr.(*exec.ExitError); ok {
state = "exit=" + itoa(ee.ExitCode())
} else {
state = "err:" + werr.Error()
}
}
if ctx.Err() == context.DeadlineExceeded {
state = "exit=timeout"
}
return out, state
}
func TestSandboxHomeHidden(t *testing.T) {
requireBwrap(t)
home, err := os.UserHomeDir()
if err != nil || home == "" || !strings.HasPrefix(home, "/home/") {
t.Skipf("home %q not under /home; fixture would not be shadowed", home)
}
secret := filepath.Join(home, ".agent-ext16-marker")
if err := os.WriteFile(secret, []byte("topsecret"), 0o600); err != nil {
t.Skipf("cannot create home fixture: %v", err)
}
defer os.Remove(secret)
out, state := sandboxRun(t, "cat \"$HOME/.agent-ext16-marker\" 2>err.txt; cat err.txt; exit 7", 30*time.Second)
if strings.Contains(out, "topsecret") {
t.Fatalf("sandbox read real home secret (state=%s out=%q)", state, out)
}
if state == "exit=0" || state == "exit=7" && strings.Contains(out, "topsecret") {
t.Fatalf("unexpected success: state=%s out=%q", state, out)
}
}
func TestSandboxSiblingSessionUnreadable(t *testing.T) {
requireBwrap(t)
base := t.TempDir()
a, b := filepath.Join(base, "session-a"), filepath.Join(base, "session-b")
if err := os.MkdirAll(a, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(b, 0o755); err != nil {
t.Fatal(err)
}
marker := filepath.Join(b, "other-session.txt")
if err := os.WriteFile(marker, []byte("others-data"), 0o600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var buf bytes.Buffer
spec := ExecSpec{
Bin: "/bin/bash", Args: []string{"-c", "cat '" + marker + "'"}, Dir: a,
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
}
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
if err != nil {
t.Fatal(err)
}
werr := proc.Wait()
if werr == nil || buf.String() == "others-data" {
t.Fatalf("sibling session readable inside sandbox: wait=%v out=%q", werr, buf.String())
}
}
func TestSandboxNoNetwork(t *testing.T) {
requireBwrap(t)
out, state := sandboxRun(t,
"timeout 5 bash -c 'echo > /dev/tcp/1.1.1.1/80' 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED",
30*time.Second)
if strings.Contains(out, "NET-OPEN") {
t.Fatalf("network reachable in sandbox (state=%s)", state)
}
if !strings.Contains(out, "NET-BLOCKED") {
t.Fatalf("probe did not run: state=%s out=%q", state, out)
}
}
func TestSandboxForkBombContained(t *testing.T) {
requireBwrap(t)
// 确定性进程数压测(替代经典 :(){ :|:& };:——其在沙箱 user namespace 内
// 递归行为依 bash 实现而变,不适合做门禁):400 个并发 sleep 撞 ulimit -u
// 256 的墙,fork: retry 即限额生效的直接证据。
start := time.Now()
out, state := sandboxRun(t,
"n=0; for i in $(seq 1 400); do sleep 5 & n=$((n+1)); done; echo spawned=$n; wait",
20*time.Second)
elapsed := time.Since(start)
if elapsed > 40*time.Second {
t.Fatalf("spawn storm stalled the runner: %s", elapsed)
}
if !strings.Contains(out, "fork: retry") {
t.Fatalf("process limit not enforced (state=%s out=%.200q)", state, out)
}
if state == "exit=0" && strings.Contains(out, "spawned=400") {
t.Fatalf("400 processes spawned unbounded: %q", out)
}
}
func TestSandboxOrphansDieWithNamespace(t *testing.T) {
requireBwrap(t)
out, state := sandboxRun(t, ":(){ :|:& };:; echo gone", 30*time.Second)
if state != "exit=0" || !strings.Contains(out, "gone") {
t.Fatalf("state=%s out=%q", state, out)
}
time.Sleep(500 * time.Millisecond) // 给孤儿进程留存活窗口
hb, err := exec.Command("bash", "-c", "ps -eo args | grep -c '[s]\\.sh' || true").Output()
if err != nil {
t.Skipf("host ps probe failed: %v", err)
}
if strings.TrimSpace(string(hb)) != "0" {
t.Fatalf("orphaned bomb survived outside the namespace: %s", hb)
}
}
func TestSandboxTimeoutKills(t *testing.T) {
requireBwrap(t)
start := time.Now()
_, state := sandboxRun(t, "sleep 60", 3*time.Second)
if d := time.Since(start); d > 20*time.Second {
t.Fatalf("timeout did not kill the sandbox: %s", d)
}
if state != "exit=timeout" {
t.Fatalf("state = %s, want exit=timeout", state)
}
}
func TestSandboxSessionWriteVisible(t *testing.T) {
requireBwrap(t)
dir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
script := filepath.Join(dir, "w.sh")
if err := os.WriteFile(script, []byte("printf 'docx-ish' > \"$(dirname \"$0\")/out.txt\"\n"), 0o600); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
spec := ExecSpec{
Bin: "/bin/bash", Args: []string{script}, Dir: dir,
Env: []string{"PATH=/usr/bin:/bin", "HOME=/home/agent"},
}
proc, err := sandboxBackend{}.Start(ctx, spec, &buf)
if err != nil {
t.Fatal(err)
}
if err := proc.Wait(); err != nil {
t.Fatalf("sandbox task failed: %v out=%q", err, buf.String())
}
got, err := os.ReadFile(filepath.Join(dir, "out.txt"))
if err != nil || string(got) != "docx-ish" {
t.Fatalf("product not visible on host: %v %q", err, got)
}
}
// direct 档回归:后端抽象后 runInterpreter 语义不变(退出码/超时文本口径)。
func TestDirectBackendRunInterpreterUnchanged(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash missing")
}
dir := t.TempDir()
p := filepath.Join(dir, "x.sh")
if err := os.WriteFile(p, []byte("echo hi; exit 7\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := runInterpreter(context.Background(), "bash", p, dir, 30, nil)
if err != nil || !strings.HasPrefix(got, "exit=7\n") || !strings.Contains(got, "hi") {
t.Fatalf("direct regression: err=%v got=%q", err, got)
}
p2 := filepath.Join(dir, "t.sh")
if err := os.WriteFile(p2, []byte("sleep 60\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err = runInterpreter(context.Background(), "bash", p2, dir, 2, nil)
if err != nil || !strings.HasPrefix(got, "exit=timeout(2s)") {
t.Fatalf("direct timeout regression: err=%v got=%q", err, got)
}
}
+6 -18
View File
@@ -131,6 +131,7 @@ func resolveInterpreter(name string) (string, error) {
}
// runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。
// 进程启动经 EXT-16 执行后端(direct=现状直启 / sandbox=bwrap 隔离档)。
// 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。
func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string)) (string, error) {
if parent == nil {
@@ -145,24 +146,11 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
}
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutS)*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, scriptPath)
cmd.Dir = dir
cmd.Env = execEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = 2 * time.Second
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil
}
return err
spec := ExecSpec{
Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnv(),
SkillsDir: skillsRoot, Timeout: time.Duration(timeoutS) * time.Second,
}
pr, pw := io.Pipe()
cmd.Stdout = pw
cmd.Stderr = pw
var buf bytes.Buffer
readDone := make(chan struct{})
go func() {
@@ -183,13 +171,13 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
}
}
}()
err = cmd.Start()
proc, err := currentExecBackend().Start(ctx, spec, pw)
if err != nil {
_ = pw.Close()
<-readDone
return "", fmt.Errorf("%s: %w", interpreter, err)
}
err = cmd.Wait()
err = proc.Wait()
_ = pw.Close()
<-readDone
text := truncateOutput(buf.Bytes())
+3 -3
View File
@@ -20,9 +20,9 @@ func (dummyCtx) PeerConversations() ([]core.Conversation, error) { return nil, n
func (dummyCtx) PeerConversationMessages(id string, limit int) ([]core.Message, error) {
return nil, nil
}
func (dummyCtx) OwnerID() string { return "o" }
func (dummyCtx) WorkspaceRoot() string { return "" }
func (dummyCtx) SettingBool(string, bool) bool { return false }
func (dummyCtx) OwnerID() string { return "o" }
func (dummyCtx) WorkspaceRoot() string { return "" }
func (dummyCtx) SettingBool(string, bool) bool { return false }
// TestHTTPExecutorTemplateAndPath:模板变量注入、结果路径裁剪、错误状态。
func TestHTTPExecutorTemplateAndPath(t *testing.T) {
+16
View File
@@ -22,7 +22,23 @@ type TavilySettings struct {
// RegisterSearchTools 注册 tavily_search(完整参数面)与 web_search(别名)。
// 工具面与 agent-native runtime/tools/search.go 逐字段对齐(名称/参数/输出/失败降级文案)。
func RegisterSearchTools(r *Registry, provider agenttools.SearchProvider) error {
return registerSearch(r, func(ToolExecCtx) agenttools.SearchProvider { return provider })
}
// RegisterLazySearchTools 注册按调用时取密钥的搜索工具:provider 在每次调用时
// 重建(设置页/svc 播种改了 tavily 密钥即刻生效,不再需要重启进程——
// 实测播种后 tavily 工具要重启才注册)。configure 返回 nil 时不静默降级,
// 返回明确的未配置提示。
func RegisterLazySearchTools(r *Registry, configure func() agenttools.SearchProvider) error {
return registerSearch(r, func(ToolExecCtx) agenttools.SearchProvider { return configure() })
}
func registerSearch(r *Registry, providerFor func(ToolExecCtx) agenttools.SearchProvider) error {
tavily := func(rc ToolExecCtx, args json.RawMessage) (string, error) {
provider := providerFor(rc)
if provider == nil {
return "", fmt.Errorf("搜索未配置:settings.tools.tavily.apiKeys(或对应 secret)为空;配置后立即生效,无需重启")
}
return execSearch(provider, args)
}
if err := r.Register(core.ToolDesc{
+1 -1
View File
@@ -6,9 +6,9 @@ import (
"testing"
"time"
core "agent-system-core"
"agent-system-backend/eval"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
// ab_demo_test.go — Q3 WP5 验收:同批任务(含 demo.brief 场景)经两个入口
+12 -12
View File
@@ -35,20 +35,20 @@ import (
// 宿主 intent 步的意图 ID(目录由本包合成,处理函数在 host.go)。
const (
IntentHitl = "wf.hitl"
IntentTool = "wf.tool"
IntentHitl = "wf.hitl"
IntentTool = "wf.tool"
IntentArtifact = "wf.artifact"
IntentCard = "wf.card"
IntentScript = "wf.script"
IntentCritic = "wf.critic"
IntentCard = "wf.card"
IntentScript = "wf.script"
IntentCritic = "wf.critic"
)
// CriticPair 记录一个「原步 ↔ 评审步」映射(收口后校验最终判定用)。
type CriticPair struct {
StepID string // 被评审的原步 ID
StepTitle string
EvalID string // 评审步 ID(<StepID>_critic)
VerdictField string // 黑板判定字段名
StepID string // 被评审的原步 ID
StepTitle string
EvalID string // 评审步 ID(<StepID>_critic)
VerdictField string // 黑板判定字段名
}
// Compiled 是一次编译产物。
@@ -86,7 +86,7 @@ func Compile(def core.WorkflowDef, vars workflow.Vars, opts CompileOptions, know
var steps []squad.StepSpec
var agents []squad.RoleSpec
agentSeen := map[string]bool{}
prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退)
prevDef := "" // 上一个 def 步 ID(artifact 空内容的 prev 回退)
prevChain := "" // 图链上的前驱(含评审步)
for i, raw := range def.Steps {
@@ -376,8 +376,8 @@ func buildCatalog() *squad.IntentCatalog {
Revision: 1,
Markdown: "# 工作流宿主能力\n\n- wf.hitl 人工问答\n- wf.tool 宿主工具\n- wf.artifact 工件落库\n- wf.card 收件箱卡\n- wf.script 脚本宿主\n- wf.critic 步骤验收\n",
Specs: map[string]squad.IntentSpecView{
IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")),
IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")),
IntentHitl: spec(IntentHitl, "人工问答步", req("question"), opt("step_id"), opt("kind"), opt("choices"), opt("allow_free_input"), opt("free_input_hint")),
IntentTool: spec(IntentTool, "宿主工具步", req("tool"), opt("step_id"), opt("args")),
IntentArtifact: spec(IntentArtifact, "工件步", req("name"), opt("step_id"), opt("content"), opt("content_ref")),
IntentCard: spec(IntentCard, "收件箱卡步", req("template"), opt("step_id")),
IntentScript: spec(IntentScript, "脚本步", req("path"), opt("step_id"), opt("model")),
+22 -1
View File
@@ -19,7 +19,9 @@ import (
// 2.4.0-experimental:EXT-11 新增 SetScopedHostFactory/NewScopedHost(按 owner+pluginID
// 重建受限宿主句柄,供下游恢复路径)。
// 2.5.0-experimental:Options.DisableExecTools(执行原语启动级硬关闭)。
const SurfaceVersion = "2.5.0-experimental"
// 2.6.0-experimental:Options.ExecBackend(EXT-16 执行后端 direct|sandbox)。
// 2.7.0-experimental:Registry.AddIntent 与 IntentSpec(G2-2 意图声明面)。
const SurfaceVersion = "2.7.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
@@ -40,6 +42,20 @@ type Registry interface {
// RegisterResourceAuthorizer 注册 EXT-8 资源授权适配器(进程内扩展);
// 首个注册者生效。声明式扩展不支持(回调需宿主进程内代码)。
RegisterResourceAuthorizer(authz ResourceAuthorizer) error
// AddIntent 声明自然语言意图(G2-2):短语命中后路由到已注册命令——
// 经同一命令总线分发(同审计信封与 HITL 收口)。规格见 IntentSpec。
AddIntent(spec json.RawMessage) error
}
// IntentSpec 意图声明规格(G2-2):id 形如 <domain>.<intent>;phrases 是触发
// 短语(宿主归一后包含匹配,最长短语优先);command 须为已注册命令 id;
// params 为参数模板(调用方可覆盖)。宿主侧路由入口:system.intent 命令。
type IntentSpec struct {
ID string `json:"id"`
Phrases []string `json:"phrases"`
Command string `json:"command"`
Params map[string]any `json:"params,omitempty"`
Description string `json:"description,omitempty"`
}
// InProc 是随下游二进制编译进来的扩展。
@@ -68,6 +84,11 @@ type Options struct {
// register_artifact 及小队内核执行配置):用户设置 runtime.exec 无法覆盖。
// 零值=false=现状。多用户共享部署在执行隔离就位前应置 true。
DisableExecTools bool
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档
// (模型生成代码按不可信对待:断网、只读系统根、tmpfs 遮蔽 /home 与 /tmp、
// 仅会话工作目录与技能目录挂载、进程数限额、超时杀整个沙箱)。
// DisableExecTools 优先级高于此选择。
ExecBackend string
}
var (
+1
View File
@@ -15,6 +15,7 @@ func init() {
DisableUploadIndexing: opt.DisableUploadIndexing,
DisableConvCardMirror: opt.DisableConvCardMirror,
DisableExecTools: opt.DisableExecTools,
ExecBackend: opt.ExecBackend,
})
})
}
+2
View File
@@ -19,6 +19,7 @@ Host.TaskCreate
Host.TaskUpdate
HostClient
InProc
IntentSpec
Main
NewHostClient
NewScopedHost
@@ -27,6 +28,7 @@ RegisterCompiled
Registry
Registry.AddCommand
Registry.AddCommandHandler
Registry.AddIntent
Registry.AddSkill
Registry.Note
Registry.RegisterResourceAuthorizer