Repository navigation
Expand file tree
/
Copy pathcli_chat.go
More file actions
205 lines (190 loc) · 6.86 KB
/
Copy pathcli_chat.go
File metadata and controls
205 lines (190 loc) · 6.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/jmwri/flockdeck/internal/agent"
"github.com/jmwri/flockdeck/internal/chat"
"github.com/jmwri/flockdeck/internal/chat/tool"
"github.com/jmwri/flockdeck/internal/creds"
)
// runChat implements the `chat` subcommand: Flockdeck's own chat client, run inside
// a pane's pseudo-terminal and talking straight to a model API.
//
// It is a subcommand rather than a second binary for the same reason `hook` and
// `spawn` are: there is one artifact to ship, and a pane that can run Flockdeck can
// run everything Flockdeck does. The pane's callback address, its token and its id
// come from the environment it was started with, so a chat in a pane reports
// its own status without being told how.
func runChat(args []string) error {
opts, err := chat.ParseArgs(args, os.Stderr)
if errors.Is(err, chat.ErrHelpShown) {
return nil
}
if err != nil {
return err
}
if opts.Cwd == "" {
opts.Cwd, _ = os.Getwd()
}
fillFromCatalog(&opts)
opts.Tools = chatTools(opts.Cwd, storedKeyVars(opts.Agent))
opts.Models = catalogModels(opts.Agent)
chat.KeyStore = storedKey
chat.EndpointStore = catalogEndpoint
return chat.Run(context.Background(), opts)
}
// catalogEndpoint is the address an agent talks to now, as the catalog --
// agents.json included -- has it, which `flockdeck keys endpoint` may have
// changed since the pane was started.
func catalogEndpoint(agentID string) string {
for _, s := range agent.Load().Specs {
if s.ID == agentID {
return s.API.BaseURL
}
}
return ""
}
// fillFromCatalog takes the agent's wire, address and key variables from its
// catalog entry, agents.json included, wherever the command line and the
// environment left them unsaid.
//
// A pane is started as `flockdeck chat --agent <id>` and nothing more of the
// endpoint (chatArgs, in the agent package, writes the id in precisely so that
// the entry can be read from here). Left unread, every API agent's chat spoke
// the Anthropic wire to Anthropic's address, asked for ANTHROPIC_API_KEY, and
// sent an OpenAI or Gemini key there; a local model server's address was never
// used at all, and `flockdeck keys endpoint` changed nothing a pane would see.
// A flag or a variable that does say is still what the chat uses.
func fillFromCatalog(opts *chat.Options) {
if opts.Agent == "" {
return
}
for _, s := range agent.Load().Specs {
if s.ID != opts.Agent {
continue
}
if opts.Wire == "" {
opts.Wire = s.API.Wire
}
if opts.BaseURL == "" {
opts.BaseURL = s.API.BaseURL
}
if len(opts.KeyEnv) == 0 {
opts.KeyEnv = s.API.KeyEnv
}
// Told so that a resumed conversation switched to another model is
// not put back on the default the pane was started with.
opts.DefaultModel = s.DefaultModel
return
}
}
// catalogModels are the models the pane's agent offers in the catalog,
// agents.json included, for /model to list and pick from by number.
func catalogModels(agentID string) []chat.ModelChoice {
for _, s := range agent.Load().Specs {
if s.ID != agentID {
continue
}
out := make([]chat.ModelChoice, 0, len(s.Models))
for _, m := range s.Models {
out = append(out, chat.ModelChoice{ID: m.ID, Name: m.Name, Note: m.Note})
}
return out
}
return nil
}
// storedKeyVars are the variables in this process's environment that carry the
// key Flockdeck stored for an agent.
//
// A stored key reaches the pane in its environment, so that the chat can use
// it; the promise is that it reaches that one process. Every command the model
// runs would inherit it too, and `env` or `set` would print it into the
// conversation and on to the vendor. A key the user exported themselves is
// theirs to hand on and is not in the store, so it is not matched.
func storedKeyVars(agentID string) []string {
key := storedKey(agentID)
if key == "" {
return nil
}
var names []string
for _, kv := range os.Environ() {
if name, v, ok := strings.Cut(kv, "="); ok && v == key {
names = append(names, name)
}
}
return names
}
// storedKey is the key `flockdeck keys set` stored for an agent, or "".
//
// A pane is handed its stored key in its environment, but a chat started by
// hand has nobody to hand it one -- and the error it prints without a key
// tells the user to run `flockdeck keys set`, which would otherwise make no
// difference to it at all.
func storedKey(agentID string) string {
return creds.Resolve(agent.Spec{ID: agentID}).Secret()
}
// chatTools are what the model in a chat pane can do besides talk: read and
// write files, look around the directory, and run a command, each of them
// confined to the pane's own working directory.
//
// A pane whose directory cannot be resolved gets no tools rather than no pane.
// The confinement every tool depends on is that resolved directory, so a tool
// set built without one would be a tool set confined to nothing; a conversation
// with no tools is a perfectly good thing for a pane to be, and it says so.
//
// hide names the variables that carry a key Flockdeck handed the pane, which
// no command the model runs should inherit.
func chatTools(cwd string, hide []string) []chat.Tool {
set, err := tool.New(cwd)
if err != nil {
fmt.Fprintln(os.Stderr, "flockdeck chat: no tools in this pane:", err)
return nil
}
set.HideEnv(hide...)
tools := set.Tools()
out := make([]chat.Tool, 0, len(tools))
for _, t := range tools {
out = append(out, chatTool{t})
}
return out
}
// chatTool presents one of the pane's tools to the chat loop.
//
// The two halves were built to the same description and describe a tool
// slightly differently: the tools carry a name and a typed parameter object,
// the loop wants a description and the raw JSON Schema to hand whichever wire
// format it is talking. Translating here keeps that difference out of both.
type chatTool struct{ t tool.Tool }
func (c chatTool) Name() string { return c.t.Name() }
func (c chatTool) Describe() chat.Schema {
s := c.t.Describe()
params, err := json.Marshal(s.Params)
if err != nil {
// A schema that will not encode would be sent as an empty object,
// which reads to the model as a tool taking no arguments at all.
// Describing it as taking nothing it can name is the safer lie.
params = nil
}
return chat.Schema{Description: s.Description, Params: params}
}
func (c chatTool) Approval(args json.RawMessage) string { return c.t.Approval(args) }
func (c chatTool) Run(ctx context.Context, args json.RawMessage) (string, error) {
return c.t.Run(ctx, args)
}
// AlwaysKey lets a tool that can be approved in bulk say so: run_command as the
// command prefix -- "go test", "npm run" -- and write_file and edit_file as
// every edit to a file, which is what the user would be agreeing to for the
// rest of the session.
func (c chatTool) AlwaysKey(args json.RawMessage) string {
p, ok := c.t.(interface {
Prefix(json.RawMessage) string
})
if !ok {
return ""
}
return p.Prefix(args)
}