Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions docs/content/building-gormes/architecture_plan/progress.json
Original file line number Diff line number Diff line change
Expand Up @@ -1595,9 +1595,9 @@
{
"name": "Goncho context representation options",
"priority": "P3",
"status": "planned",
"status": "complete",
"contract": "honcho_context exposes the Honcho v3 session.context representation controls while preserving current same-chat defaults",
"contract_status": "draft",
"contract_status": "validated",
"slice_size": "small",
"execution_owner": "memory",
"trust_class": [
Expand All @@ -1614,9 +1614,6 @@
"internal/goncho/types.go",
"internal/tools/honcho_tools.go"
],
"blocked_by": [
"Honcho-compatible scope/source tool schema"
],
"ready_when": [
"honcho_context already accepts peer, query, max_tokens, session_key, scope, and sources through the Goncho service."
],
Expand All @@ -1633,7 +1630,7 @@
"limit_to_session=true cannot widen recall through scope=user.",
"Fields that need the future observation table report explicit unsupported evidence."
],
"note": "Docs study landed from Honcho v3 get-context and representation-scopes docs. This is the smallest public-edge slice before full SDK context parity: add typed option fields and schema visibility, but do not claim that summary, observation, or dialectic-backed representation retrieval is complete.",
"note": "TDD landed: ContextParams and the honcho_context schema expose optional peer_target, peer_perspective, limit_to_session, search_top_k, search_max_distance, include_most_frequent, and max_conclusions. Omitted fields preserve same-chat context defaults; limit_to_session=true fails closed without session_key and cannot widen through scope=user; unsupported directional and semantic representation options return structured unavailable evidence. Summaries, observations, and dialectic-backed retrieval remain separate slices.",
"write_scope": [
"internal/goncho/",
"internal/tools/",
Expand Down
182 changes: 182 additions & 0 deletions internal/goncho/context_options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package goncho

import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
)

func TestContractContextParamsRepresentationOptionsJSONShape(t *testing.T) {
limitToSession := true
searchTopK := 10
searchMaxDistance := 0.75
includeMostFrequent := true
maxConclusions := 25

raw, err := json.Marshal(ContextParams{
Peer: "user-juan",
Query: "Atlas",
SessionKey: "sess-telegram",
PeerTarget: "user-juan",
PeerPerspective: "assistant",
LimitToSession: &limitToSession,
SearchTopK: &searchTopK,
SearchMaxDistance: &searchMaxDistance,
IncludeMostFrequent: &includeMostFrequent,
MaxConclusions: &maxConclusions,
})
if err != nil {
t.Fatal(err)
}

text := string(raw)
for _, want := range []string{
`"peer_target":"user-juan"`,
`"peer_perspective":"assistant"`,
`"limit_to_session":true`,
`"search_top_k":10`,
`"search_max_distance":0.75`,
`"include_most_frequent":true`,
`"max_conclusions":25`,
} {
if !strings.Contains(text, want) {
t.Fatalf("ContextParams JSON missing %s in %s", want, raw)
}
}
}

func TestService_ContextOmittedRepresentationOptionsPreservesSameChatDefault(t *testing.T) {
svc, cleanup := newTestService(t)
defer cleanup()

ctx := context.Background()
seedContextOptionConclusions(t, ctx, svc, "telegram:6586915095")

got, err := svc.Context(ctx, ContextParams{
Peer: "telegram:6586915095",
Query: "codename",
MaxTokens: 400,
SessionKey: "telegram:6586915095",
})
if err != nil {
t.Fatal(err)
}

if !slices.Contains(got.Conclusions, "same-chat codename orchid") {
t.Fatalf("Conclusions = %#v, want same-chat conclusion", got.Conclusions)
}
if slices.Contains(got.Conclusions, "other-chat codename orchid") {
t.Fatalf("Conclusions leaked other-chat result: %#v", got.Conclusions)
}
if len(got.Unavailable) != 0 {
t.Fatalf("Unavailable = %#v, want no degraded evidence for omitted options", got.Unavailable)
}
}

func TestService_ContextLimitToSessionCannotWidenThroughUserScope(t *testing.T) {
svc, cleanup := newTestService(t)
defer cleanup()

ctx := context.Background()
seedContextOptionConclusions(t, ctx, svc, "user-juan")
limitToSession := true

got, err := svc.Context(ctx, ContextParams{
Peer: "user-juan",
Query: "codename",
MaxTokens: 400,
Scope: "user",
LimitToSession: &limitToSession,
})
if err != nil {
t.Fatal(err)
}

if len(got.Conclusions) != 0 {
t.Fatalf("Conclusions = %#v, want no widened recall without a session_key", got.Conclusions)
}
requireUnavailableFields(t, got, "limit_to_session")
}

func TestService_ContextUnsupportedRepresentationOptionsReturnUnavailableEvidence(t *testing.T) {
svc, cleanup := newTestService(t)
defer cleanup()

ctx := context.Background()
searchTopK := 10
searchMaxDistance := 0.8
includeMostFrequent := true
maxConclusions := 25

got, err := svc.Context(ctx, ContextParams{
Peer: "telegram:6586915095",
Query: "coding preferences",
MaxTokens: 400,
SessionKey: "telegram:6586915095",
PeerTarget: "telegram:6586915095",
PeerPerspective: "assistant",
SearchTopK: &searchTopK,
SearchMaxDistance: &searchMaxDistance,
IncludeMostFrequent: &includeMostFrequent,
MaxConclusions: &maxConclusions,
})
if err != nil {
t.Fatal(err)
}

requireUnavailableFields(t, got,
"peer_target",
"peer_perspective",
"search_top_k",
"search_max_distance",
"include_most_frequent",
"max_conclusions",
)
}

func requireUnavailableFields(t *testing.T, got ContextResult, wantFields ...string) {
t.Helper()

seen := make(map[string]ContextUnavailableEvidence, len(got.Unavailable))
for _, item := range got.Unavailable {
seen[item.Field] = item
}

for _, field := range wantFields {
item, ok := seen[field]
if !ok {
t.Fatalf("Unavailable = %#v, missing field %q", got.Unavailable, field)
}
if item.Reason == "" {
t.Fatalf("Unavailable[%s] has empty reason: %#v", field, item)
}
}
}

func seedContextOptionConclusions(t *testing.T, ctx context.Context, svc *Service, peer string) {
t.Helper()

for _, item := range []struct {
sessionKey string
conclusion string
}{
{
sessionKey: "telegram:6586915095",
conclusion: "same-chat codename orchid",
},
{
sessionKey: "discord:channel-9",
conclusion: "other-chat codename orchid",
},
} {
if _, err := svc.Conclude(ctx, ConcludeParams{
Peer: peer,
Conclusion: item.conclusion,
SessionKey: item.sessionKey,
}); err != nil {
t.Fatalf("seed conclusion %q: %v", item.conclusion, err)
}
}
}
90 changes: 77 additions & 13 deletions internal/goncho/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,22 +164,41 @@ func (s *Service) Context(ctx context.Context, params ContextParams) (ContextRes
if peer == "" {
return ContextResult{}, fmt.Errorf("goncho: peer is required")
}
sessionKey := strings.TrimSpace(params.SessionKey)
unavailable := contextUnavailableEvidence(params)

card, err := getPeerCard(ctx, s.db, s.workspaceID, peer)
if err != nil {
return ContextResult{}, err
}

searchResult, err := s.Search(ctx, SearchParams{
Peer: peer,
Query: params.Query,
MaxTokens: params.MaxTokens,
SessionKey: params.SessionKey,
Scope: params.Scope,
Sources: params.Sources,
})
if err != nil {
return ContextResult{}, err
searchResult := SearchResultSet{
WorkspaceID: s.workspaceID,
Peer: peer,
Query: params.Query,
}
if limitToSession(params) && sessionKey == "" {
unavailable = append(unavailable, ContextUnavailableEvidence{
Field: "limit_to_session",
Capability: "session_scoped_representation",
Reason: "limit_to_session requires session_key; recall was not widened through scope=user",
})
} else {
scope := params.Scope
if limitToSession(params) {
scope = ""
}
searchResult, err = s.Search(ctx, SearchParams{
Peer: peer,
Query: params.Query,
MaxTokens: params.MaxTokens,
SessionKey: sessionKey,
Scope: scope,
Sources: params.Sources,
})
if err != nil {
return ContextResult{}, err
}
}

conclusions := make([]string, 0, len(searchResult.Results))
Expand All @@ -191,8 +210,8 @@ func (s *Service) Context(ctx context.Context, params ContextParams) (ContextRes
}

recentMessages := []MessageSlice{}
if strings.TrimSpace(params.SessionKey) != "" {
recentMessages, err = recentTurns(ctx, s.db, params.SessionKey, s.recentLimit)
if sessionKey != "" {
recentMessages, err = recentTurns(ctx, s.db, sessionKey, s.recentLimit)
if err != nil {
return ContextResult{}, err
}
Expand All @@ -201,15 +220,60 @@ func (s *Service) Context(ctx context.Context, params ContextParams) (ContextRes
return ContextResult{
WorkspaceID: s.workspaceID,
Peer: peer,
SessionKey: strings.TrimSpace(params.SessionKey),
SessionKey: sessionKey,
PeerCard: card,
Representation: buildRepresentation(peer, card, conclusions),
Summary: "",
Conclusions: conclusions,
RecentMessages: recentMessages,
Unavailable: unavailable,
}, nil
}

func limitToSession(params ContextParams) bool {
return params.LimitToSession != nil && *params.LimitToSession
}

func contextUnavailableEvidence(params ContextParams) []ContextUnavailableEvidence {
var unavailable []ContextUnavailableEvidence

if strings.TrimSpace(params.PeerTarget) != "" {
unavailable = append(unavailable, ContextUnavailableEvidence{
Field: "peer_target",
Capability: "directional_representation",
Reason: "peer_target requires observer/observed representation storage",
})
}
if strings.TrimSpace(params.PeerPerspective) != "" {
unavailable = append(unavailable, ContextUnavailableEvidence{
Field: "peer_perspective",
Capability: "directional_representation",
Reason: "peer_perspective requires observer/observed representation storage",
})
}
if params.SearchTopK != nil {
unavailable = append(unavailable, unsupportedSemanticRepresentationOption("search_top_k"))
}
if params.SearchMaxDistance != nil {
unavailable = append(unavailable, unsupportedSemanticRepresentationOption("search_max_distance"))
}
if params.IncludeMostFrequent != nil {
unavailable = append(unavailable, unsupportedSemanticRepresentationOption("include_most_frequent"))
}
if params.MaxConclusions != nil {
unavailable = append(unavailable, unsupportedSemanticRepresentationOption("max_conclusions"))
}
return unavailable
}

func unsupportedSemanticRepresentationOption(field string) ContextUnavailableEvidence {
return ContextUnavailableEvidence{
Field: field,
Capability: "semantic_representation_options",
Reason: "semantic representation options require the future observation table",
}
}

func buildRepresentation(peer string, card, conclusions []string) string {
if len(card) == 0 && len(conclusions) == 0 {
return "No stored representation for " + peer + "."
Expand Down
44 changes: 30 additions & 14 deletions internal/goncho/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,19 @@ type SearchResultSet struct {

// ContextParams controls honcho_context reads.
type ContextParams struct {
Peer string `json:"peer"`
Query string `json:"query,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
SessionKey string `json:"session_key,omitempty"`
Scope string `json:"scope,omitempty"`
Sources []string `json:"sources,omitempty"`
Peer string `json:"peer"`
Query string `json:"query,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
SessionKey string `json:"session_key,omitempty"`
Scope string `json:"scope,omitempty"`
Sources []string `json:"sources,omitempty"`
PeerTarget string `json:"peer_target,omitempty"`
PeerPerspective string `json:"peer_perspective,omitempty"`
LimitToSession *bool `json:"limit_to_session,omitempty"`
SearchTopK *int `json:"search_top_k,omitempty"`
SearchMaxDistance *float64 `json:"search_max_distance,omitempty"`
IncludeMostFrequent *bool `json:"include_most_frequent,omitempty"`
MaxConclusions *int `json:"max_conclusions,omitempty"`
}

// MessageSlice is one recent message excerpt included in context responses.
Expand All @@ -86,14 +93,23 @@ type MessageSlice struct {
Content string `json:"content"`
}

// ContextUnavailableEvidence names a requested context capability that Goncho
// accepted but cannot yet fulfill with the current local storage model.
type ContextUnavailableEvidence struct {
Field string `json:"field"`
Capability string `json:"capability"`
Reason string `json:"reason"`
}

// ContextResult is the stable JSON shape for honcho_context.
type ContextResult struct {
WorkspaceID string `json:"workspace_id"`
Peer string `json:"peer"`
SessionKey string `json:"session_key,omitempty"`
PeerCard []string `json:"peer_card"`
Representation string `json:"representation"`
Summary string `json:"summary,omitempty"`
Conclusions []string `json:"conclusions,omitempty"`
RecentMessages []MessageSlice `json:"recent_messages,omitempty"`
WorkspaceID string `json:"workspace_id"`
Peer string `json:"peer"`
SessionKey string `json:"session_key,omitempty"`
PeerCard []string `json:"peer_card"`
Representation string `json:"representation"`
Summary string `json:"summary,omitempty"`
Conclusions []string `json:"conclusions,omitempty"`
RecentMessages []MessageSlice `json:"recent_messages,omitempty"`
Unavailable []ContextUnavailableEvidence `json:"unavailable,omitempty"`
}
Loading