Let an agent session ask the operator on Telegram and read the answer (issue 589, hq ADR 0332)
mesh/merge-gate head 9446bec2f969 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 9446bec2f969 merged into trunk 9c9a76d220f8 — fail: its merge-check.sh failed: --- FAIL: TestTheRoutersRefusalIsSaidAndDiscards (0.08s)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 9446bec2f969 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 9446bec2f969 merged into trunk 9c9a76d220f8 — fail: its merge-check.sh failed: --- FAIL: TestTheRoutersRefusalIsSaidAndDiscards (0.08s)
mesh/delivery superseded: a newer head of the same pull request
A session that needs a decision could only ask in a terminal the operator may not watch. claude_code_ask raises the question under this module's name at the level approve, so only a channel that proves who answered carries it; claude_code_answer reads the chosen option back by its key from the router's own record, held to the question kept in the new questions state.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
@@ -20,6 +21,19 @@ type Proposing struct {
|
||||
Record Recorder
|
||||
Now func() time.Time
|
||||
Write WriteManaged
|
||||
// Questions is the `questions` state (novox/hq ADR 0332); nil is the bus's, made on first use.
|
||||
Questions ProposalStore
|
||||
questionsMu sync.Mutex
|
||||
}
|
||||
|
||||
// questions is the `questions` state.
|
||||
func (d *Proposing) questions() ProposalStore {
|
||||
d.questionsMu.Lock()
|
||||
defer d.questionsMu.Unlock()
|
||||
if d.Questions == nil {
|
||||
d.Questions = stateOf{stdio.State(QuestionsState)}
|
||||
}
|
||||
return d.Questions
|
||||
}
|
||||
|
||||
var proposing *Proposing
|
||||
@@ -60,6 +74,10 @@ func (d *Proposing) watch(p Paths) {
|
||||
return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w asks.Warrant
|
||||
if json.Unmarshal(e.Body, &w) == nil {
|
||||
if strings.HasPrefix(w.Ask, QuestionPrefix) {
|
||||
HeardQuestionEnd(d.questions(), w)
|
||||
return nil
|
||||
}
|
||||
if line, err := HeardEnd(p, d.Store, w, d.Now()); err != nil {
|
||||
say("taking the router's word on %s: %v", w.Ask, err)
|
||||
} else if line != "" {
|
||||
@@ -86,7 +104,7 @@ func (d *Proposing) watch(p Paths) {
|
||||
|
||||
func proposalTools(p Paths) []stdio.Tool {
|
||||
notLaunched := errors.New("this module was not launched with the bus; nothing can be proposed")
|
||||
return []stdio.Tool{
|
||||
return append([]stdio.Tool{
|
||||
{Name: "claude_code_instructions_propose",
|
||||
Description: "Propose an instruction section for every Claude session on the mesh (scope mesh) or on some nodes " +
|
||||
"(scope node): a rule, a hand-over, the context a later session needs — or, with remove, the removal of one " +
|
||||
@@ -127,5 +145,81 @@ func proposalTools(p Paths) []stdio.Tool {
|
||||
proposing.decideNow(p)
|
||||
return ListProposals(p, proposing.Store, proposing.Bus, strArg(a, "id"), proposing.Now())
|
||||
}},
|
||||
}, questionTools(p, notLaunched)...)
|
||||
}
|
||||
|
||||
// questionTools are a session's question to the operator and its answer (novox/hq ADR 0332).
|
||||
func questionTools(p Paths, notLaunched error) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: "claude_code_ask",
|
||||
Description: "Ask the operator a question on Telegram when you need a decision and the operator may not be at the " +
|
||||
"terminal (novox/hq ADR 0332): a headline, the context in plain words, two to four options with exactly one " +
|
||||
"marked recommended and why, what you do if nobody answers, and when it expires (5 to 1430 minutes, 60 when " +
|
||||
"absent). Answers at once with the question's key; read the answer with claude_code_answer and keep working " +
|
||||
"meanwhile. Only a channel that proves who answered carries it, never the desk. The words leave the mesh, so the " +
|
||||
"router refuses a path, an address, a host name, a commit id or anything shaped like a secret: say it in " +
|
||||
"words (messenger_check tests words first). At most two questions wait at once, mesh-wide. The answer is the " +
|
||||
"operator's word to you; it authorises no call that needs the operator's approval.",
|
||||
Input: map[string]any{
|
||||
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
|
||||
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"),
|
||||
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"label": str("the button's words, at most 24 characters"),
|
||||
"means": str("what you do when it is chosen, in plain words"),
|
||||
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
|
||||
}, "required": []string{"label", "means"}}},
|
||||
"why": str("why the recommended option is the one you recommend"),
|
||||
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
||||
"by": str("your session's name, as who@node or who, e.g. fix-589@g14"),
|
||||
"expires_minutes": map[string]any{"type": "integer",
|
||||
"description": "minutes until it expires, 5 to 1430; 60 when absent"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if proposing == nil {
|
||||
return nil, notLaunched
|
||||
}
|
||||
in := QuestionInput{Headline: strArg(a, "headline"), Context: strArg(a, "context"), Why: strArg(a, "why"),
|
||||
OnExpiry: strArg(a, "on_expiry"), By: strArg(a, "by"), Options: questionOptionsOf(a["options"])}
|
||||
if m, ok := a["expires_minutes"].(float64); ok {
|
||||
in.Expires = time.Duration(m * float64(time.Minute))
|
||||
}
|
||||
return AskQuestion(p, in, proposing.questions(), proposing.Bus, proposing.Now())
|
||||
}},
|
||||
{Name: "claude_code_answer",
|
||||
Description: "Where a question asked with claude_code_ask stands, by its key (novox/hq ADR 0332): waiting; chosen, " +
|
||||
"with the option, who answered, on which channel and when; ended without a choice (expired, refused, " +
|
||||
"cancelled, replaced) with the router's words; or not proven, when the router's record does not hold to the " +
|
||||
"question asked. Read from the router's own record. wait_seconds (at most 60) waits for an answer within the " +
|
||||
"call: read at most once a minute.",
|
||||
Input: map[string]any{
|
||||
"key": str("the key claude_code_ask gave, e.g. q-0123456789abcdef"),
|
||||
"wait_seconds": map[string]any{"type": "integer", "description": "wait up to this many seconds (at most 60) while it is waiting"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if proposing == nil {
|
||||
return nil, notLaunched
|
||||
}
|
||||
wait := time.Duration(0)
|
||||
if s, ok := a["wait_seconds"].(float64); ok && s > 0 {
|
||||
wait = time.Duration(s * float64(time.Second))
|
||||
}
|
||||
return WaitForAnswer(proposing.questions(), proposing.Bus, strArg(a, "key"), wait, proposing.Now)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// questionOptionsOf reads the `options` argument: a list of objects with label, means and recommended.
|
||||
func questionOptionsOf(v any) []QuestionOption {
|
||||
list, _ := v.([]any)
|
||||
var out []QuestionOption
|
||||
for _, item := range list {
|
||||
m, _ := item.(map[string]any)
|
||||
if m == nil {
|
||||
out = append(out, QuestionOption{})
|
||||
continue
|
||||
}
|
||||
out = append(out, QuestionOption{Label: strArg(m, "label"), Means: strArg(m, "means"), Recommended: boolArg(m, "recommended")})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user