Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
32fb1d3
feat: proper rendering of api.Text and Textable in all formatters
moshloop Nov 3, 2025
4687661
feat: refactor formatters to use FieldValue.Text and PrettyData.Pretty()
moshloop Nov 4, 2025
a8be24d
chore: misc
moshloop Nov 4, 2025
c777998
chore: update examples
moshloop Nov 4, 2025
eb6a679
fix: restore table rendering temporarily, fix ANSI test checks
moshloop Nov 4, 2025
4d8ab4c
fix: wrap string values in Text for TextList compatibility
moshloop Nov 4, 2025
24a2c20
fix: add missing types and methods for compilation
moshloop Nov 4, 2025
e60bcff
refactor: remove Formatted(), Plain(), Markdown(), HTML() from FieldV…
moshloop Nov 4, 2025
be7a811
fix: update test to handle Textable interface properly
moshloop Nov 4, 2025
ab7ea01
wip
moshloop Nov 4, 2025
74aeefc
misc
moshloop Nov 5, 2025
d6800db
fix: improve HTML tree rendering with embedded assets
moshloop Nov 5, 2025
7cba29c
refactor: extract inline CSS/JS to go:embed files and use Iconify che…
moshloop Nov 5, 2025
5e1402b
fix: align tree chevrons to top
moshloop Nov 5, 2025
b6123b8
fix: use interface assertion in NewTypedValue for nested trees
moshloop Nov 5, 2025
be5dc1a
feat: implement nested table rendering in HTML with compact support
moshloop Nov 5, 2025
b6ab2be
feat(ai): add LLM agent adapter with structured output support
moshloop Nov 7, 2025
222dab6
fix(task): prevent hanging and terminal corruption in task manager
moshloop Nov 14, 2025
44c4a63
fix(task): resolve race conditions in batch.go
moshloop Nov 16, 2025
a77b8aa
fix(task): resolve data races in Task and Manager
moshloop Nov 16, 2025
90b7b9c
fix: formatting improvements
moshloop Nov 17, 2025
d9d468c
fix: args handling in clicky.AddCommand
moshloop Nov 17, 2025
80289dd
chore: ai refactorings
moshloop Nov 17, 2025
3868624
fix: batch race conditions
moshloop Nov 17, 2025
684686d
fix: parse max-w unit suffixes (ch, px, rem, em)
moshloop Nov 17, 2025
5471a3c
feat: default to truncate-suffix when constraints specified
moshloop Nov 17, 2025
a4ee1da
fix: prevent truncate class from overwriting explicit max-w
moshloop Nov 17, 2025
b6e0935
feat: migrate terminal tables to lipgloss (hybrid approach)
moshloop Nov 17, 2025
5571de3
feat: migrate tree rendering to lipgloss
moshloop Nov 17, 2025
7a85328
chore: switch tree/table printing back to lipgloss
moshloop Nov 17, 2025
937606c
chore: fix nested html table output
moshloop Nov 18, 2025
a1ac86c
chore: fix nested html tree output
moshloop Nov 18, 2025
3c8c682
chore: html fixes
moshloop Nov 19, 2025
c680026
chore: review fixes
moshloop Nov 19, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,6 @@ clicky
formatters/.playwright-mcp/
__debug*

ginkgo.report
*.bak
examples/uber_demo/uber_demo
12 changes: 8 additions & 4 deletions ai/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/flanksource/clicky/ai/cache"

"github.com/spf13/pflag"
)

Expand Down Expand Up @@ -92,6 +93,7 @@ func NewAgentManager(config AgentConfig) *AgentManager {
}

func GetDefaultAgent() (Agent, error) {

manager := NewAgentManager(DefaultConfig())
return manager.GetDefaultAgent()
}
Expand All @@ -111,6 +113,7 @@ func (am *AgentManager) GetAgent(agentType AgentType) (Agent, error) {
agent, err = NewClaudeAgent(am.config)
case AgentTypeAider:
agent, err = NewAiderAgent(am.config)

default:
return nil, fmt.Errorf("unsupported agent type: %s", agentType)
}
Expand Down Expand Up @@ -185,12 +188,13 @@ var defaultConfig AgentConfig = AgentConfig{
Type: AgentTypeClaude,
Model: "claude-haiku-4-5",
MaxTokens: 10000,
MaxConcurrent: 3,
MaxConcurrent: 4,
Debug: false,
Verbose: false,
Temperature: 0.2,
CacheTTL: 24 * time.Hour, // Default 24 hour TTL
NoCache: false,
// Temperature: 0.2,
StrictMCPConfig: true,
CacheTTL: 24 * time.Hour, // Default 24 hour TTL
NoCache: false,
}

// DefaultConfig returns a default agent configuration
Expand Down
100 changes: 95 additions & 5 deletions ai/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ai

import (
"context"
"encoding/json"
"time"

"github.com/flanksource/clicky/api"
Expand Down Expand Up @@ -40,27 +41,63 @@ type AgentConfig struct {
NoCache bool `json:"no_cache,omitempty"`
}

func (a AgentConfig) Pretty() api.Text {
t := api.Text{}.Append(string(a.Type))
if a.Model != "" {
t = t.Space().Append(a.Model, "font-mono")
}
if a.NoCache {
t = t.Space().Append(" No Cache", "text-orange-500")
}
return t
}

// PromptRequest represents a request to process a prompt
type PromptRequest struct {
Context map[string]string `json:"context,omitempty"`
Name string `json:"name"`
Prompt string `json:"prompt"`
StructuredOutput interface{} `json:"structured_output,omitempty"` // Schema for structured JSON output
}

// TypedPromptRequest represents a type-safe request with structured output
type TypedPromptRequest[T any] struct {
Context map[string]string `json:"context,omitempty"`
Name string `json:"name"`
Prompt string `json:"prompt"`
}

// PromptResponse represents the response from processing a prompt
type PromptResponse struct {
Request PromptRequest `json:"request,omitempty"`
Result string `json:"result"`
Costs Costs `json:"costs,omitempty"`
Model string `json:"model,omitempty"`
Error string `json:"error,omitempty"`
Request PromptRequest `json:"request,omitempty"`
Result string `json:"result"`
StructuredData interface{} `json:"structured_data,omitempty"` // Populated if structured output was requested
Costs Costs `json:"costs,omitempty"`
Model string `json:"model,omitempty"`
Error string `json:"error,omitempty"`
// Total wall-clock duration of the request
Duration time.Duration `json:"duration,omitempty"`
// Duration spent in the model processing as reported by the API
DurationModel time.Duration `json:"duration_model,omitempty"`
CacheHit bool `json:"cache_hit,omitempty"`
}

// TypedPromptResponse represents a type-safe response with structured output
type TypedPromptResponse[T any] struct {
Request TypedPromptRequest[T] `json:"request,omitempty"`
Data T `json:"data"`
Costs Costs `json:"costs,omitempty"`
Model string `json:"model,omitempty"`
Error string `json:"error,omitempty"`
Duration time.Duration `json:"duration,omitempty"`
DurationModel time.Duration `json:"duration_model,omitempty"`
CacheHit bool `json:"cache_hit,omitempty"`
}

func (tr TypedPromptResponse[T]) IsOK() bool {
return tr.Error == ""
}

func (pr PromptResponse) IsOK() bool {
return pr.Error == ""
}
Expand Down Expand Up @@ -111,3 +148,56 @@ type Agent interface {
// Close cleans up resources
Close() error
}

// ExecutePromptTyped is a generic helper that executes a prompt with type-safe structured output.
// It automatically cleans up the JSON response before unmarshaling into the target type.
func ExecutePromptTyped[T any](ctx context.Context, agent Agent, request TypedPromptRequest[T]) (*TypedPromptResponse[T], error) {
// Convert to regular PromptRequest
var schema T
promptReq := PromptRequest{
Context: request.Context,
Name: request.Name,
Prompt: request.Prompt,
StructuredOutput: &schema,
}

// Execute the request
resp, err := agent.ExecutePrompt(ctx, promptReq)
if err != nil {
return &TypedPromptResponse[T]{
Request: request,
Error: err.Error(),
}, err
}

// Convert to typed response
typedResp := &TypedPromptResponse[T]{
Request: request,
Costs: resp.Costs,
Model: resp.Model,
Duration: resp.Duration,
DurationModel: resp.DurationModel,
CacheHit: resp.CacheHit,
Error: resp.Error,
}

// If there's structured data, convert it
if resp.StructuredData != nil {
if data, ok := resp.StructuredData.(*T); ok {
typedResp.Data = *data
} else {
// Try to marshal and unmarshal through JSON
jsonData, marshalErr := json.Marshal(resp.StructuredData)
if marshalErr != nil {
typedResp.Error = fmt.Sprintf("failed to marshal structured data: %v", marshalErr)
return typedResp, marshalErr
}
if unmarshalErr := json.Unmarshal(jsonData, &typedResp.Data); unmarshalErr != nil {
typedResp.Error = fmt.Sprintf("failed to unmarshal structured data to target type: %v", unmarshalErr)
return typedResp, unmarshalErr
}
}
}

return typedResp, nil
}
Comment on lines +152 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Fix missing fmt import and consider explicit handling of structured data types

Two points here:

  1. Compile-time issue (must fix)
    The error-path branches use fmt.Sprintf:

    typedResp.Error = fmt.Sprintf("failed to marshal structured data: %v", marshalErr)
    ...
    typedResp.Error = fmt.Sprintf("failed to unmarshal structured data to target type: %v", unmarshalErr)

    but fmt is not imported in this file. Add it to the import block:

    import (
        "context"
        "encoding/json"
        "fmt"
        "time"
        // ...
    )

    Without this, the package won’t compile.

  2. StructuredData conversion edge cases
    The generic conversion logic:

    if data, ok := resp.StructuredData.(*T); ok {
        typedResp.Data = *data
    } else {
        jsonData, marshalErr := json.Marshal(resp.StructuredData)
        ...
        json.Unmarshal(jsonData, &typedResp.Data)
    }

    works well when StructuredData is a *T, a T, or a JSON-serializable map/slice. If StructuredData can ever be a json.RawMessage, []byte, or a raw JSON string, json.Marshal will either base64-encode the bytes or double-encode the JSON string, which could cause unexpected unmarshal failures.

    If those types are possible here, consider special‑casing them, e.g.:

    • json.RawMessage / []byte: use the bytes directly as jsonData.
    • string: treat as raw JSON and []byte(stringVal).

    Otherwise, this path is fine as-is.


🏁 Script executed:

#!/bin/bash
# Check imports at the top of ai/api.go
head -50 ai/api.go | grep -E "^import|^[[:space:]]*\"" | head -20

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Search for StructuredData field definition and PromptRequest/TypedPromptResponse types
rg -n "StructuredData|PromptRequest|TypedPromptResponse" ai/api.go -A2 -B2 | head -100

Length of output: 2892


🏁 Script executed:

#!/bin/bash
# Search for where StructuredData is assigned or populated in the codebase
rg -n "StructuredData\s*=" --type=go -C3 | head -80

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if fmt is used elsewhere in ai/api.go
rg -n "fmt\." ai/api.go

Length of output: 200


🏁 Script executed:

#!/bin/bash
# Find Agent interface implementations (implementations of ExecutePrompt)
rg -n "func.*ExecutePrompt" --type=go -B5 -A15 | head -150

Length of output: 5395


🏁 Script executed:

#!/bin/bash
# Search for any code that creates PromptResponse and sets StructuredData
rg -n "StructuredData:" --type=go -B3 -A3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if json.RawMessage is imported or used anywhere in the codebase
rg -n "json\.RawMessage|RawMessage" --type=go

Length of output: 0


Fix missing fmt import

The code uses fmt.Sprintf at lines 192 and 196, but fmt is not in the import block. This will cause a compile error. Add it:

import (
    "context"
    "encoding/json"
    "fmt"
    "time"
    // ...
)

StructuredData conversion edge cases (optional)

The conversion logic handles *T, T, and JSON-serializable types via json.Marshal/Unmarshal. While special-casing for json.RawMessage, []byte, or raw JSON strings would be defensive (to avoid base64-encoding or double-encoding), no evidence of these types currently being assigned to StructuredData was found in the codebase. If such types are introduced in future, consider explicit handling.

🤖 Prompt for AI Agents
In ai/api.go around lines 152 to 203, the code calls fmt.Sprintf on lines ~192
and ~196 but fmt is not imported; add "fmt" to the import block (with other
standard libs) to resolve the compile error. Additionally, optionally harden the
StructuredData conversion: explicitly handle json.RawMessage, []byte and raw
JSON string cases before generic JSON marshal/unmarshal to avoid double-encoding
edge cases.

5 changes: 2 additions & 3 deletions ai/claude_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

flanksourcecontext "github.com/flanksource/commons/context"
"github.com/flanksource/commons/logger"
"github.com/samber/lo"

"github.com/flanksource/clicky"
"github.com/flanksource/clicky/ai/cache"
Expand Down Expand Up @@ -134,7 +135,6 @@ func (ca *ClaudeAgent) ExecutePrompt(ctx context.Context, request PromptRequest)

task := clicky.StartTask(request.Name,
func(ctx flanksourcecontext.Context, t *clicky.Task) (interface{}, error) {
t.Infof("Starting Claude request")

resp, execErr := ca.executeClaude(ctx, request, t)
if execErr != nil {
Expand Down Expand Up @@ -184,7 +184,6 @@ func (ca *ClaudeAgent) ExecuteBatch(ctx context.Context, requests []PromptReques

task := clicky.StartTask(req.Name,
func(ctx flanksourcecontext.Context, t *clicky.Task) (interface{}, error) {
t.Infof("Processing request")

response, err := ca.executeClaude(t.Context(), req, t)

Expand Down Expand Up @@ -299,7 +298,7 @@ func (ca *ClaudeAgent) executeClaude(ctx context.Context, request PromptRequest,
}

if logger.V(3).Enabled() {
task.Tracef("%s", prompt)
task.Tracef("%s", lo.Ellipsis(prompt, 200))
}

startTime := time.Now()
Expand Down
33 changes: 32 additions & 1 deletion ai/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ type Cost struct {
OutputCost float64 `json:"output_cost"`
}

func (c Cost) IsEmpty() bool {
return c.InputTokens == 0 && c.OutputTokens == 0 && c.InputCost == 0 && c.OutputCost == 0
}

func (c Cost) Total() float64 {
return c.InputCost + c.OutputCost
}
Expand All @@ -31,11 +35,38 @@ func (c Cost) Pretty() api.Text {
}

if c.InputTokens+c.OutputTokens > 0 {
t = t.Space().Append(fmt.Sprintf("(%d in, %d out)", api.Human(c.InputTokens), api.Human(c.OutputTokens)), "text-muted")
t = t.Space().Append(fmt.Sprintf("(%v in, %v out)", api.Human(c.InputTokens), api.Human(c.OutputTokens)), "text-muted")
}
return t
}

func (c Costs) AggregateByModel() Costs {
modelCosts := make(map[string]Cost)
for _, cost := range c {
if cost.IsEmpty() {
continue
}
model := cost.Model
if model == "" {
model = "unknown"
}

existing, ok := modelCosts[model]
if !ok {
existing = Cost{Model: model}
}

modelCosts[model] = existing.Add(cost)
}

aggregated := Costs{}
for _, cost := range modelCosts {
aggregated = append(aggregated, cost)
}

return aggregated
}

func (c Costs) Sum() Cost {
total := Cost{}
for _, cost := range c {
Expand Down
79 changes: 79 additions & 0 deletions ai/json_cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package ai

import (
"encoding/json"
"regexp"
"strings"
)

var (
// Match markdown code blocks with optional language specifier
codeBlockRegex = regexp.MustCompile("(?s)```(?:json)?\\s*(.+?)```")

// Match JSON objects or arrays
jsonObjectRegex = regexp.MustCompile(`(?s)\{.*\}`)
jsonArrayRegex = regexp.MustCompile(`(?s)\[.*\]`)
)

// CleanupJSONResponse attempts to extract and clean JSON from LLM responses
// that may contain markdown formatting, explanatory text, or other noise.
//
// It tries the following strategies in order:
// 1. Extract JSON from markdown code blocks (```json or ```)
// 2. Extract the first JSON object {...}
// 3. Extract the first JSON array [...]
// 4. Return the trimmed original string
//
// After extraction, it validates that the result is valid JSON.
func CleanupJSONResponse(response string) string {
// Trim whitespace
response = strings.TrimSpace(response)

// If it's already valid JSON, return as-is
if isValidJSON(response) {
return response
}

// Strategy 1: Extract from markdown code blocks
if matches := codeBlockRegex.FindStringSubmatch(response); len(matches) > 1 {
extracted := strings.TrimSpace(matches[1])
if isValidJSON(extracted) {
return extracted
}
}

// Strategy 2: Extract first JSON object
if match := jsonObjectRegex.FindString(response); match != "" {
if isValidJSON(match) {
return match
}
}

// Strategy 3: Extract first JSON array
if match := jsonArrayRegex.FindString(response); match != "" {
if isValidJSON(match) {
return match
}
}

// Strategy 4: Return original trimmed
return response
}

// isValidJSON checks if a string is valid JSON
func isValidJSON(s string) bool {
var js interface{}
return json.Unmarshal([]byte(s), &js) == nil
}

// UnmarshalWithCleanup attempts to unmarshal JSON with automatic cleanup
func UnmarshalWithCleanup(data string, v interface{}) error {
// First try direct unmarshal
if err := json.Unmarshal([]byte(data), v); err == nil {
return nil
}

// If that fails, try with cleanup
cleaned := CleanupJSONResponse(data)
return json.Unmarshal([]byte(cleaned), v)
}
4 changes: 2 additions & 2 deletions api/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func rowToCELMap(row PrettyDataRow) map[string]interface{} {
// - bool for booleans
// - string for strings
// - time.Time for dates
result[key] = fieldValue.Primitive()
result[key] = fieldValue.String()
}
return result
}
Expand Down Expand Up @@ -256,7 +256,7 @@ func getVariableDeclarationsFromRow(row PrettyDataRow) []cel.EnvOption {
var decls []cel.EnvOption

for key, fieldValue := range row {
celType := inferCELTypeFromValue(fieldValue.Primitive())
celType := inferCELTypeFromValue(fieldValue.String())
decls = append(decls, cel.Variable(key, celType))
}

Expand Down
Loading