Give conversation runs the intent journal via JournalOnly sessions (N3 WP2)
ci / vet-and-test (pull_request) Successful in 42s
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:
Vendored
+1
-1
Submodule deps/agent-native updated: cb21ec4e2a...79e1a8fa85
Vendored
+1
-1
Submodule deps/agent-squad updated: 931e5ae410...90077b6601
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "" }
|
||||||
|
|||||||
Reference in New Issue
Block a user