Give conversation runs the intent journal via JournalOnly sessions (N3 WP2)
ci / vet-and-test (pull_request) Successful in 42s

Why: the product conversation path had zero mid-turn durability
(llm_direct ran without Session — intents, recovery pairing, and
side-effect replay semantics never applied). Transcript-as-authority
would have frozen the assembled prefix, so the journal is wired in
JournalOnly mode: recovery runs against the transcript, the context
stays on the ctxasm pipeline, and recovered outcomes inject as a
system note.
Evidence: go test -count=1 ./... green;
TestDirectRunPairsAfterKillSeed (crash-window transcript gets the
crashInterrupted pair, run proceeds) and
TestDirectRunSeedsStoreHistoryOnce (history appears exactly once in
every request, never enters the journal); the forged-marker and
harness suites pass unchanged.
Impact: conversation runs now journal tool intents and recover them on
the next run; no transparent resume (sealed interrupted stays; the
user keeps chatting). Opening the transcript fails soft (falls back to
the old no-session behavior).
Refs: Agent-Native#31, Agent-Squad#35, N3 plan WP2
This commit is contained in:
2026-10-07 15:26:27 +00:00
parent 7a6a6075c2
commit 9f3fdb26e2
5 changed files with 153 additions and 2 deletions
+17
View File
@@ -3,6 +3,8 @@ package runtime
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log"
"path/filepath"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@@ -12,6 +14,7 @@ import (
"agent-native/runtime/gates" "agent-native/runtime/gates"
"agent-native/runtime/hooks" "agent-native/runtime/hooks"
agentllm "agent-native/runtime/llm" agentllm "agent-native/runtime/llm"
agentsession "agent-native/runtime/session"
agenttools "agent-native/runtime/tools" agenttools "agent-native/runtime/tools"
"agent-system-backend/internal/ctxasm" "agent-system-backend/internal/ctxasm"
"agent-system-backend/internal/squadadapter" "agent-system-backend/internal/squadadapter"
@@ -158,6 +161,18 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
} }
window, reserve, buffer := contextWindow(st.eff) window, reserve, buffer := contextWindow(st.eff)
// 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对——
// JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线
// 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无
// Session(旧行为,不阻断对话)。
var sessStore *agentsession.Store
if sandbox := conversationSandbox(rc); sandbox != "" {
if opened, oerr := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl")); oerr == nil {
sessStore = opened
} else {
log.Printf("[runtime] open session transcript: %v", oerr)
}
}
bus := hooks.New() bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome { bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop { if c.Event != hooks.Stop {
@@ -172,6 +187,8 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
res, err := nruntime.Run(rc.Context(), nruntime.Config{ res, err := nruntime.Run(rc.Context(), nruntime.Config{
Model: model, Model: model,
History: history, History: history,
Session: sessStore,
JournalOnly: sessStore != nil,
Tools: reg, Tools: reg,
Stream: wrapped, Stream: wrapped,
MaxTurns: maxTurns, MaxTurns: maxTurns,
+126
View File
@@ -0,0 +1,126 @@
package runtime
import (
"context"
"path/filepath"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
agentsession "agent-native/runtime/session"
core "agent-system-core"
)
// ---- N3 WP2:产品会话路径持久化(验收① 产品层机器证据)----
func textTurns(texts ...string) [][]agentllm.Event {
out := make([][]agentllm.Event, 0, len(texts))
for _, t := range texts {
out = append(out, []agentllm.Event{
{Kind: agentllm.KindTextDelta, Text: t},
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: t, StopReason: agentllm.StopEnd}},
})
}
return out
}
// 杀窗口形态的种子转录:新 run 前配对修复成立,且 run 正常产出。
func TestDirectRunPairsAfterKillSeed(t *testing.T) {
sandbox := t.TempDir()
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, m := range []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "sys"},
{Role: agentllm.RoleUser, Content: "早前"},
{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls, ToolCalls: []agentllm.ToolCall{{ID: "c1", Name: "run_python", Arguments: `{}`}}},
} {
if err := sess.Append(m); err != nil {
t.Fatal(err)
}
}
rc := &fakeRunCtx{
ctx: context.Background(), sandbox: sandbox,
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
history: []core.Message{
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "还在吗"}}},
},
}
r := &LLMRunner{Stream: scriptStream(textTurns("还在"), nil)}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
loaded, err := sess.Load()
if err != nil {
t.Fatal(err)
}
var paired bool
for _, m := range loaded {
if m.Role == agentllm.RoleTool && m.ToolCallID == "c1" {
paired = true
if !m.IsError || !strings.Contains(m.Content, "进程中断") {
t.Fatalf("兜底配对形态不对:%+v", m)
}
}
}
if !paired {
t.Fatalf("杀窗口调用未配对:%#v", loaded)
}
}
// JournalOnly 语义:上下文始终由 store 历史组装(每跑都在、恰好一次、
// 不丢不重);journal 不吞种子历史。
func TestDirectRunSeedsStoreHistoryOnce(t *testing.T) {
sandbox := t.TempDir()
rc := &fakeRunCtx{
ctx: context.Background(), sandbox: sandbox,
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
history: []core.Message{
{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "历史一"}}},
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "回复一"}}},
{ID: "m3", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "最新"}}},
},
}
var requests [][]agentllm.Message
base := scriptStream(textTurns("一答", "二答"), nil)
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
requests = append(requests, append([]agentllm.Message{}, msgs...))
return base(ctx, model, msgs, tools, opts)
}}
if err := r.Execute(rc); err != nil {
t.Fatalf("first Execute: %v", err)
}
// 第二跑:同一 rc(新 user 消息由 history 尾部提供)。
rc.history = append(rc.history, core.Message{ID: "m4", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "再来"}}})
if err := r.Execute(rc); err != nil {
t.Fatalf("second Execute: %v", err)
}
if len(requests) < 2 {
t.Fatalf("stream 调用数 = %d", len(requests))
}
for i, req := range requests {
count := 0
for _, m := range req {
if m.Content == "历史一" {
count++
}
}
if count != 1 {
t.Fatalf("第 %d 跑历史中「历史一」必须恰好一次,得到 %d", i+1, count)
}
}
sess, err := agentsession.Open(filepath.Join(sandbox, ".agentx", "transcript.jsonl"))
if err != nil {
t.Fatal(err)
}
loaded, err := sess.Load()
if err != nil {
t.Fatal(err)
}
for _, m := range loaded {
if m.Content == "历史一" {
t.Fatalf("journal 不得吞种子历史:%#v", loaded)
}
}
}
+8
View File
@@ -39,6 +39,7 @@ type fakeRunCtx struct {
history []core.Message // 非 nil 时覆盖默认历史 history []core.Message // 非 nil 时覆盖默认历史
bgID string bgID string
bgErr error bgErr error
sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试)
bgCalls int bgCalls int
} }
@@ -94,6 +95,13 @@ func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID,
} }
return f.bgID, nil return f.bgID, nil
} }
func (f *fakeRunCtx) ConversationSandbox() string {
if f.sandbox != "" {
return f.sandbox
}
return ""
}
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil } func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil } func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
func (f *fakeRunCtx) Subtask() string { return "" } func (f *fakeRunCtx) Subtask() string { return "" }