blob: c46fcc02e618e23b4690b6518a62c7b8b4793bc2 [file] [log] [blame]
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001package conversation
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "maps"
10 "math/rand/v2"
11 "slices"
12 "strings"
13 "sync"
14 "time"
15
16 "github.com/oklog/ulid/v2"
17 "github.com/richardlehane/crock32"
18 "sketch.dev/llm"
19 "sketch.dev/skribe"
20)
21
22type Listener interface {
23 // TODO: Content is leaking an anthropic API; should we avoid it?
24 // TODO: Where should we include start/end time and usage?
25 OnToolCall(ctx context.Context, convo *Convo, toolCallID string, toolName string, toolInput json.RawMessage, content llm.Content)
26 OnToolResult(ctx context.Context, convo *Convo, toolCallID string, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error)
27 OnRequest(ctx context.Context, convo *Convo, requestID string, msg *llm.Message)
28 OnResponse(ctx context.Context, convo *Convo, requestID string, msg *llm.Response)
29}
30
31type NoopListener struct{}
32
33func (n *NoopListener) OnToolCall(ctx context.Context, convo *Convo, id string, toolName string, toolInput json.RawMessage, content llm.Content) {
34}
35
36func (n *NoopListener) OnToolResult(ctx context.Context, convo *Convo, id string, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error) {
37}
38
39func (n *NoopListener) OnResponse(ctx context.Context, convo *Convo, id string, msg *llm.Response) {
40}
41func (n *NoopListener) OnRequest(ctx context.Context, convo *Convo, id string, msg *llm.Message) {}
42
43var ErrDoNotRespond = errors.New("do not respond")
44
45// A Convo is a managed conversation with Claude.
46// It automatically manages the state of the conversation,
47// including appending messages send/received,
48// calling tools and sending their results,
49// tracking usage, etc.
50//
51// Exported fields must not be altered concurrently with calling any method on Convo.
52// Typical usage is to configure a Convo once before using it.
53type Convo struct {
54 // ID is a unique ID for the conversation
55 ID string
56 // Ctx is the context for the entire conversation.
57 Ctx context.Context
58 // Service is the LLM service to use.
59 Service llm.Service
60 // Tools are the tools available during the conversation.
61 Tools []*llm.Tool
62 // SystemPrompt is the system prompt for the conversation.
63 SystemPrompt string
64 // PromptCaching indicates whether to use Anthropic's prompt caching.
65 // See https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#continuing-a-multi-turn-conversation
66 // for the documentation. At request send time, we set the cache_control field on the
67 // last message. We also cache the system prompt.
68 // Default: true.
69 PromptCaching bool
70 // ToolUseOnly indicates whether Claude may only use tools during this conversation.
71 // TODO: add more fine-grained control over tool use?
72 ToolUseOnly bool
73 // Parent is the parent conversation, if any.
74 // It is non-nil for "subagent" calls.
75 // It is set automatically when calling SubConvo,
76 // and usually should not be set manually.
77 Parent *Convo
78 // Budget is the budget for this conversation (and all sub-conversations).
79 // The Conversation DOES NOT automatically enforce the budget.
80 // It is up to the caller to call OverBudget() as appropriate.
81 Budget Budget
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +000082 // Hidden indicates that the output of this conversation should be hidden in the UI.
83 // This is useful for subconversations that can generate noisy, uninteresting output.
84 Hidden bool
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085
86 // messages tracks the messages so far in the conversation.
87 messages []llm.Message
88
89 // Listener receives messages being sent.
90 Listener Listener
91
92 muToolUseCancel *sync.Mutex
93 toolUseCancel map[string]context.CancelCauseFunc
94
95 // Protects usage. This is used for subconversations (that share part of CumulativeUsage) as well.
96 mu *sync.Mutex
97 // usage tracks usage for this conversation and all sub-conversations.
98 usage *CumulativeUsage
99}
100
101// newConvoID generates a new 8-byte random id.
102// The uniqueness/collision requirements here are very low.
103// They are not global identifiers,
104// just enough to distinguish different convos in a single session.
105func newConvoID() string {
106 u1 := rand.Uint32()
107 s := crock32.Encode(uint64(u1))
108 if len(s) < 7 {
109 s += strings.Repeat("0", 7-len(s))
110 }
111 return s[:3] + "-" + s[3:]
112}
113
114// New creates a new conversation with Claude with sensible defaults.
115// ctx is the context for the entire conversation.
116func New(ctx context.Context, srv llm.Service) *Convo {
117 id := newConvoID()
118 return &Convo{
119 Ctx: skribe.ContextWithAttr(ctx, slog.String("convo_id", id)),
120 Service: srv,
121 PromptCaching: true,
122 usage: newUsage(),
123 Listener: &NoopListener{},
124 ID: id,
125 muToolUseCancel: &sync.Mutex{},
126 toolUseCancel: map[string]context.CancelCauseFunc{},
127 mu: &sync.Mutex{},
128 }
129}
130
131// SubConvo creates a sub-conversation with the same configuration as the parent conversation.
132// (This propagates context for cancellation, HTTP client, API key, etc.)
133// The sub-conversation shares no messages with the parent conversation.
134// It does not inherit tools from the parent conversation.
135func (c *Convo) SubConvo() *Convo {
136 id := newConvoID()
137 return &Convo{
138 Ctx: skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
139 Service: c.Service,
140 PromptCaching: c.PromptCaching,
141 Parent: c,
142 // For convenience, sub-convo usage shares tool uses map with parent,
143 // all other fields separate, propagated in AddResponse
144 usage: newUsageWithSharedToolUses(c.usage),
145 mu: c.mu,
146 Listener: c.Listener,
147 ID: id,
148 // Do not copy Budget. Each budget is independent,
149 // and OverBudget checks whether any ancestor is over budget.
150 }
151}
152
153func (c *Convo) SubConvoWithHistory() *Convo {
154 id := newConvoID()
155 return &Convo{
156 Ctx: skribe.ContextWithAttr(c.Ctx, slog.String("convo_id", id), slog.String("parent_convo_id", c.ID)),
157 Service: c.Service,
158 PromptCaching: c.PromptCaching,
159 Parent: c,
160 // For convenience, sub-convo usage shares tool uses map with parent,
161 // all other fields separate, propagated in AddResponse
162 usage: newUsageWithSharedToolUses(c.usage),
163 mu: c.mu,
164 Listener: c.Listener,
165 ID: id,
166 // Do not copy Budget. Each budget is independent,
167 // and OverBudget checks whether any ancestor is over budget.
168 messages: slices.Clone(c.messages),
169 }
170}
171
172// Depth reports how many "sub-conversations" deep this conversation is.
173// That it, it walks up parents until it finds a root.
174func (c *Convo) Depth() int {
175 x := c
176 var depth int
177 for x.Parent != nil {
178 x = x.Parent
179 depth++
180 }
181 return depth
182}
183
184// SendUserTextMessage sends a text message to the LLM in this conversation.
185// otherContents contains additional contents to send with the message, usually tool results.
186func (c *Convo) SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error) {
187 contents := slices.Clone(otherContents)
188 if s != "" {
189 contents = append(contents, llm.Content{Type: llm.ContentTypeText, Text: s})
190 }
191 msg := llm.Message{
192 Role: llm.MessageRoleUser,
193 Content: contents,
194 }
195 return c.SendMessage(msg)
196}
197
198func (c *Convo) messageRequest(msg llm.Message) *llm.Request {
199 system := []llm.SystemContent{}
200 if c.SystemPrompt != "" {
201 var d llm.SystemContent
202 d = llm.SystemContent{Type: "text", Text: c.SystemPrompt}
203 if c.PromptCaching {
204 d.Cache = true
205 }
206 system = []llm.SystemContent{d}
207 }
208
209 // Claude is happy to return an empty response in response to our Done() call,
210 // and, if so, you'll see something like:
211 // API request failed with status 400 Bad Request
212 // {"type":"error","error": {"type":"invalid_request_error",
213 // "message":"messages.5: all messages must have non-empty content except for the optional final assistant message"}}
214 // So, we filter out those empty messages.
215 var nonEmptyMessages []llm.Message
216 for _, m := range c.messages {
217 if len(m.Content) > 0 {
218 nonEmptyMessages = append(nonEmptyMessages, m)
219 }
220 }
221
222 mr := &llm.Request{
223 Messages: append(nonEmptyMessages, msg), // not yet committed to keeping msg
224 System: system,
225 Tools: c.Tools,
226 }
227 if c.ToolUseOnly {
228 mr.ToolChoice = &llm.ToolChoice{Type: llm.ToolChoiceTypeAny}
229 }
230 return mr
231}
232
233func (c *Convo) findTool(name string) (*llm.Tool, error) {
234 for _, tool := range c.Tools {
235 if tool.Name == name {
236 return tool, nil
237 }
238 }
239 return nil, fmt.Errorf("tool %q not found", name)
240}
241
242// insertMissingToolResults adds error results for tool uses that were requested
243// but not included in the message, which can happen in error paths like "out of budget."
244// We only insert these if there were no tool responses at all, since an incorrect
245// number of tool results would be a programmer error. Mutates inputs.
246func (c *Convo) insertMissingToolResults(mr *llm.Request, msg *llm.Message) {
247 if len(mr.Messages) < 2 {
248 return
249 }
250 prev := mr.Messages[len(mr.Messages)-2]
251 var toolUsePrev int
252 for _, c := range prev.Content {
253 if c.Type == llm.ContentTypeToolUse {
254 toolUsePrev++
255 }
256 }
257 if toolUsePrev == 0 {
258 return
259 }
260 var toolUseCurrent int
261 for _, c := range msg.Content {
262 if c.Type == llm.ContentTypeToolResult {
263 toolUseCurrent++
264 }
265 }
266 if toolUseCurrent != 0 {
267 return
268 }
269 var prefix []llm.Content
270 for _, part := range prev.Content {
271 if part.Type != llm.ContentTypeToolUse {
272 continue
273 }
274 content := llm.Content{
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700275 Type: llm.ContentTypeToolResult,
276 ToolUseID: part.ID,
277 ToolError: true,
278 ToolResult: []llm.Content{{
279 Type: llm.ContentTypeText,
280 Text: "not executed; retry possible",
281 }},
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700282 }
283 prefix = append(prefix, content)
284 msg.Content = append(prefix, msg.Content...)
285 mr.Messages[len(mr.Messages)-1].Content = msg.Content
286 }
287 slog.DebugContext(c.Ctx, "inserted missing tool results")
288}
289
290// SendMessage sends a message to Claude.
291// The conversation records (internally) all messages succesfully sent and received.
292func (c *Convo) SendMessage(msg llm.Message) (*llm.Response, error) {
293 id := ulid.Make().String()
294 mr := c.messageRequest(msg)
295 var lastMessage *llm.Message
296 if c.PromptCaching {
297 lastMessage = &mr.Messages[len(mr.Messages)-1]
298 if len(lastMessage.Content) > 0 {
299 lastMessage.Content[len(lastMessage.Content)-1].Cache = true
300 }
301 }
302 defer func() {
303 if lastMessage == nil {
304 return
305 }
306 if len(lastMessage.Content) > 0 {
307 lastMessage.Content[len(lastMessage.Content)-1].Cache = false
308 }
309 }()
310 c.insertMissingToolResults(mr, &msg)
311 c.Listener.OnRequest(c.Ctx, c, id, &msg)
312
313 startTime := time.Now()
314 resp, err := c.Service.Do(c.Ctx, mr)
315 if resp != nil {
316 resp.StartTime = &startTime
317 endTime := time.Now()
318 resp.EndTime = &endTime
319 }
320
321 if err != nil {
322 c.Listener.OnResponse(c.Ctx, c, id, nil)
323 return nil, err
324 }
325 c.messages = append(c.messages, msg, resp.ToMessage())
326 // Propagate usage to all ancestors (including us).
327 for x := c; x != nil; x = x.Parent {
328 x.usage.Add(resp.Usage)
329 }
330 c.Listener.OnResponse(c.Ctx, c, id, resp)
331 return resp, err
332}
333
334type toolCallInfoKeyType string
335
336var toolCallInfoKey toolCallInfoKeyType
337
338type ToolCallInfo struct {
339 ToolUseID string
340 Convo *Convo
341}
342
343func ToolCallInfoFromContext(ctx context.Context) ToolCallInfo {
344 v := ctx.Value(toolCallInfoKey)
345 i, _ := v.(ToolCallInfo)
346 return i
347}
348
349func (c *Convo) ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error) {
350 if resp.StopReason != llm.StopReasonToolUse {
351 return nil, nil
352 }
353 var toolResults []llm.Content
354
355 for _, part := range resp.Content {
356 if part.Type != llm.ContentTypeToolUse {
357 continue
358 }
359 c.incrementToolUse(part.ToolName)
360
361 content := llm.Content{
362 Type: llm.ContentTypeToolResult,
363 ToolUseID: part.ID,
364 }
365
366 content.ToolError = true
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700367 content.ToolResult = []llm.Content{{
368 Type: llm.ContentTypeText,
369 Text: "user canceled this too_use",
370 }}
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700371 toolResults = append(toolResults, content)
372 }
373 return toolResults, nil
374}
375
376// GetID returns the conversation ID
377func (c *Convo) GetID() string {
378 return c.ID
379}
380
381func (c *Convo) CancelToolUse(toolUseID string, err error) error {
382 c.muToolUseCancel.Lock()
383 defer c.muToolUseCancel.Unlock()
384 cancel, ok := c.toolUseCancel[toolUseID]
385 if !ok {
386 return fmt.Errorf("cannot cancel %s: no cancel function registered for this tool_use_id. All I have is %+v", toolUseID, c.toolUseCancel)
387 }
388 delete(c.toolUseCancel, toolUseID)
389 cancel(err)
390 return nil
391}
392
393func (c *Convo) newToolUseContext(ctx context.Context, toolUseID string) (context.Context, context.CancelFunc) {
394 c.muToolUseCancel.Lock()
395 defer c.muToolUseCancel.Unlock()
396 ctx, cancel := context.WithCancelCause(ctx)
397 c.toolUseCancel[toolUseID] = cancel
398 return ctx, func() { c.CancelToolUse(toolUseID, nil) }
399}
400
401// ToolResultContents runs all tool uses requested by the response and returns their results.
402// Cancelling ctx will cancel any running tool calls.
403func (c *Convo) ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error) {
404 if resp.StopReason != llm.StopReasonToolUse {
405 return nil, nil
406 }
407 // Extract all tool calls from the response, call the tools, and gather the results.
408 var wg sync.WaitGroup
409 toolResultC := make(chan llm.Content, len(resp.Content))
410 for _, part := range resp.Content {
411 if part.Type != llm.ContentTypeToolUse {
412 continue
413 }
414 c.incrementToolUse(part.ToolName)
415 startTime := time.Now()
416
417 c.Listener.OnToolCall(ctx, c, part.ID, part.ToolName, part.ToolInput, llm.Content{
418 Type: llm.ContentTypeToolUse,
419 ToolUseID: part.ID,
420 ToolUseStartTime: &startTime,
421 })
422
423 wg.Add(1)
424 go func() {
425 defer wg.Done()
426
427 content := llm.Content{
428 Type: llm.ContentTypeToolResult,
429 ToolUseID: part.ID,
430 ToolUseStartTime: &startTime,
431 }
432 sendErr := func(err error) {
433 // Record end time
434 endTime := time.Now()
435 content.ToolUseEndTime = &endTime
436
437 content.ToolError = true
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700438 content.ToolResult = []llm.Content{{
439 Type: llm.ContentTypeText,
440 Text: err.Error(),
441 }}
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700442 c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, nil, err)
443 toolResultC <- content
444 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700445 sendRes := func(toolResult []llm.Content) {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700446 // Record end time
447 endTime := time.Now()
448 content.ToolUseEndTime = &endTime
449
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700450 content.ToolResult = toolResult
451 var firstText string
452 if len(toolResult) > 0 {
453 firstText = toolResult[0].Text
454 }
455 c.Listener.OnToolResult(ctx, c, part.ID, part.ToolName, part.ToolInput, content, &firstText, nil)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700456 toolResultC <- content
457 }
458
459 tool, err := c.findTool(part.ToolName)
460 if err != nil {
461 sendErr(err)
462 return
463 }
464 // Create a new context for just this tool_use call, and register its
465 // cancel function so that it can be canceled individually.
466 toolUseCtx, cancel := c.newToolUseContext(ctx, part.ID)
467 defer cancel()
468 // TODO: move this into newToolUseContext?
469 toolUseCtx = context.WithValue(toolUseCtx, toolCallInfoKey, ToolCallInfo{ToolUseID: part.ID, Convo: c})
470 toolResult, err := tool.Run(toolUseCtx, part.ToolInput)
471 if errors.Is(err, ErrDoNotRespond) {
472 return
473 }
474 if toolUseCtx.Err() != nil {
475 sendErr(context.Cause(toolUseCtx))
476 return
477 }
478
479 if err != nil {
480 sendErr(err)
481 return
482 }
483 sendRes(toolResult)
484 }()
485 }
486 wg.Wait()
487 close(toolResultC)
488 var toolResults []llm.Content
489 for toolResult := range toolResultC {
490 toolResults = append(toolResults, toolResult)
491 }
492 if ctx.Err() != nil {
493 return nil, ctx.Err()
494 }
495 return toolResults, nil
496}
497
498func (c *Convo) incrementToolUse(name string) {
499 c.mu.Lock()
500 defer c.mu.Unlock()
501
502 c.usage.ToolUses[name]++
503}
504
505// CumulativeUsage represents cumulative usage across a Convo, including all sub-conversations.
506type CumulativeUsage struct {
507 StartTime time.Time `json:"start_time"`
508 Responses uint64 `json:"messages"` // count of responses
509 InputTokens uint64 `json:"input_tokens"`
510 OutputTokens uint64 `json:"output_tokens"`
511 CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
512 CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
513 TotalCostUSD float64 `json:"total_cost_usd"`
514 ToolUses map[string]int `json:"tool_uses"` // tool name -> number of uses
515}
516
517func newUsage() *CumulativeUsage {
518 return &CumulativeUsage{ToolUses: make(map[string]int), StartTime: time.Now()}
519}
520
521func newUsageWithSharedToolUses(parent *CumulativeUsage) *CumulativeUsage {
522 return &CumulativeUsage{ToolUses: parent.ToolUses, StartTime: time.Now()}
523}
524
525func (u *CumulativeUsage) Clone() CumulativeUsage {
526 v := *u
527 v.ToolUses = maps.Clone(u.ToolUses)
528 return v
529}
530
531func (c *Convo) CumulativeUsage() CumulativeUsage {
532 if c == nil {
533 return CumulativeUsage{}
534 }
535 c.mu.Lock()
536 defer c.mu.Unlock()
537 return c.usage.Clone()
538}
539
540func (u *CumulativeUsage) WallTime() time.Duration {
541 return time.Since(u.StartTime)
542}
543
544func (u *CumulativeUsage) DollarsPerHour() float64 {
545 hours := u.WallTime().Hours()
546 // Prevent division by very small numbers that could cause issues
547 if hours < 1e-6 {
548 return 0
549 }
550 return u.TotalCostUSD / hours
551}
552
553func (u *CumulativeUsage) Add(usage llm.Usage) {
554 u.Responses++
555 u.InputTokens += usage.InputTokens
556 u.OutputTokens += usage.OutputTokens
557 u.CacheReadInputTokens += usage.CacheReadInputTokens
558 u.CacheCreationInputTokens += usage.CacheCreationInputTokens
559 u.TotalCostUSD += usage.CostUSD
560}
561
562// TotalInputTokens returns the grand total cumulative input tokens in u.
563func (u *CumulativeUsage) TotalInputTokens() uint64 {
564 return u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens
565}
566
567// Attr returns the cumulative usage as a slog.Attr with key "usage".
568func (u CumulativeUsage) Attr() slog.Attr {
569 elapsed := time.Since(u.StartTime)
570 return slog.Group("usage",
571 slog.Duration("wall_time", elapsed),
572 slog.Uint64("responses", u.Responses),
573 slog.Uint64("input_tokens", u.InputTokens),
574 slog.Uint64("output_tokens", u.OutputTokens),
575 slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
576 slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
577 slog.Float64("total_cost_usd", u.TotalCostUSD),
578 slog.Float64("dollars_per_hour", u.TotalCostUSD/elapsed.Hours()),
579 slog.Any("tool_uses", maps.Clone(u.ToolUses)),
580 )
581}
582
583// A Budget represents the maximum amount of resources that may be spent on a conversation.
584// Note that the default (zero) budget is unlimited.
585type Budget struct {
586 MaxResponses uint64 // if > 0, max number of iterations (=responses)
587 MaxDollars float64 // if > 0, max dollars that may be spent
588 MaxWallTime time.Duration // if > 0, max wall time that may be spent
589}
590
591// OverBudget returns an error if the convo (or any of its parents) has exceeded its budget.
592// TODO: document parent vs sub budgets, multiple errors, etc, once we know the desired behavior.
593func (c *Convo) OverBudget() error {
594 for x := c; x != nil; x = x.Parent {
595 if err := x.overBudget(); err != nil {
596 return err
597 }
598 }
599 return nil
600}
601
602// ResetBudget sets the budget to the passed in budget and
603// adjusts it by what's been used so far.
604func (c *Convo) ResetBudget(budget Budget) {
605 c.Budget = budget
606 if c.Budget.MaxDollars > 0 {
607 c.Budget.MaxDollars += c.CumulativeUsage().TotalCostUSD
608 }
609 if c.Budget.MaxResponses > 0 {
610 c.Budget.MaxResponses += c.CumulativeUsage().Responses
611 }
612 if c.Budget.MaxWallTime > 0 {
613 c.Budget.MaxWallTime += c.usage.WallTime()
614 }
615}
616
617func (c *Convo) overBudget() error {
618 usage := c.CumulativeUsage()
619 // TODO: stop before we exceed the budget instead of after?
620 // Top priority is money, then time, then response count.
621 var err error
622 cont := "Continuing to chat will reset the budget."
623 if c.Budget.MaxDollars > 0 && usage.TotalCostUSD >= c.Budget.MaxDollars {
624 err = errors.Join(err, fmt.Errorf("$%.2f spent, budget is $%.2f. %s", usage.TotalCostUSD, c.Budget.MaxDollars, cont))
625 }
626 if c.Budget.MaxWallTime > 0 && usage.WallTime() >= c.Budget.MaxWallTime {
627 err = errors.Join(err, fmt.Errorf("%v elapsed, budget is %v. %s", usage.WallTime().Truncate(time.Second), c.Budget.MaxWallTime.Truncate(time.Second), cont))
628 }
629 if c.Budget.MaxResponses > 0 && usage.Responses >= c.Budget.MaxResponses {
630 err = errors.Join(err, fmt.Errorf("%d responses received, budget is %d. %s", usage.Responses, c.Budget.MaxResponses, cont))
631 }
632 return err
633}