Merge branch 'sipeed:main' into main
This commit is contained in:
commit
d8c0276b86
7 changed files with 345 additions and 41 deletions
167
.github/workflows/nightly.yml
vendored
Normal file
167
.github/workflows/nightly.yml
vendored
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
name: Nightly Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-version:
|
||||
name: Generate Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate version
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA=$(git rev-parse --short=8 HEAD)
|
||||
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||
VERSION="nightly-${DATE}-${SHA}"
|
||||
else
|
||||
VERSION="${BASE_VERSION}-nightly-${DATE}-${SHA}"
|
||||
fi
|
||||
TAG="nightly-${DATE}-${SHA}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: generate-version
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.generate-version.outputs.version }}
|
||||
run: make build-all VERSION="$VERSION"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: picoclaw-binaries
|
||||
path: build
|
||||
|
||||
build-docker:
|
||||
name: Build Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: generate-version
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ needs.generate-version.outputs.tag }}
|
||||
type=raw,value=${{ needs.generate-version.outputs.version }}
|
||||
type=raw,value=nightly
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
||||
provenance: false
|
||||
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-version, build, build-docker]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: picoclaw-binaries
|
||||
path: ./build
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ needs.generate-version.outputs.tag }}"
|
||||
TITLE="${{ needs.generate-version.outputs.version }}"
|
||||
NOTES=$'Nightly build for **${{ needs.generate-version.outputs.version }}**\n\nThis is an automated build and may be unstable. Use with caution.'
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists, updating metadata and assets..."
|
||||
gh release edit "$TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES" \
|
||||
--prerelease
|
||||
gh release upload "$TAG" build/* --clobber
|
||||
else
|
||||
echo "Creating new release $TAG..."
|
||||
gh release create "$TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES" \
|
||||
--target "${{ github.sha }}" \
|
||||
--prerelease \
|
||||
build/*
|
||||
fi
|
||||
|
||||
echo "Updating rolling 'nightly' release..."
|
||||
gh release delete nightly --cleanup-tag -y >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
gh release create nightly \
|
||||
--title "Nightly Build" \
|
||||
--notes "$NOTES" \
|
||||
--target "${{ github.sha }}" \
|
||||
--prerelease \
|
||||
build/*
|
||||
|
||||
echo "Cleaning up old nightly releases (keeping only the most recent)..."
|
||||
gh release list --limit 100 --json tagName -q '.[].tagName | select(startswith("nightly-"))' | tail -n +2 | while read -r old_tag; do
|
||||
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
|
||||
echo "Deleting old nightly release: $old_tag"
|
||||
gh release delete "$old_tag" --cleanup-tag -y || true
|
||||
fi
|
||||
done
|
||||
|
|
@ -32,9 +32,13 @@ builds:
|
|||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw
|
||||
ignore:
|
||||
- goos: windows
|
||||
|
|
@ -59,9 +63,13 @@ builds:
|
|||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw-launcher
|
||||
ignore:
|
||||
- goos: windows
|
||||
|
|
@ -86,9 +94,13 @@ builds:
|
|||
- riscv64
|
||||
- loong64
|
||||
- arm
|
||||
- s390x
|
||||
- mipsle
|
||||
goarm:
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- softfloat
|
||||
main: ./cmd/picoclaw-launcher-tui
|
||||
ignore:
|
||||
- goos: windows
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ func NewPicoclawCommand() *cobra.Command {
|
|||
cmd := &cobra.Command{
|
||||
Use: "picoclaw",
|
||||
Short: short,
|
||||
Example: "picoclaw list",
|
||||
Example: "picoclaw version",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
|
|
|
|||
|
|
@ -632,15 +632,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
}
|
||||
|
||||
route, agent, routeErr := al.resolveMessageRoute(msg)
|
||||
|
||||
// Commands are checked before requiring a successful route.
|
||||
// Global commands (/help, /show, /switch) work even when routing fails;
|
||||
// context-dependent commands check their own Runtime fields and report
|
||||
// "unavailable" when the required capability is nil.
|
||||
if response, handled := al.handleCommand(ctx, msg, agent); handled {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
if routeErr != nil {
|
||||
return "", routeErr
|
||||
}
|
||||
|
|
@ -666,7 +657,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
"route_channel": route.Channel,
|
||||
})
|
||||
|
||||
return al.runAgentLoop(ctx, agent, processOptions{
|
||||
opts := processOptions{
|
||||
SessionKey: sessionKey,
|
||||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
|
|
@ -675,7 +666,15 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: true,
|
||||
SendResponse: false,
|
||||
})
|
||||
}
|
||||
|
||||
// context-dependent commands check their own Runtime fields and report
|
||||
// "unavailable" when the required capability is nil.
|
||||
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
return al.runAgentLoop(ctx, agent, opts)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
||||
|
|
@ -1543,10 +1542,20 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
|||
return
|
||||
}
|
||||
|
||||
const (
|
||||
maxSummarizationMessages = 10
|
||||
llmMaxRetries = 3
|
||||
llmTemperature = 0.3
|
||||
fallbackMaxContentLength = 200
|
||||
)
|
||||
|
||||
// Multi-Part Summarization
|
||||
var finalSummary string
|
||||
if len(validMessages) > 10 {
|
||||
if len(validMessages) > maxSummarizationMessages {
|
||||
mid := len(validMessages) / 2
|
||||
|
||||
mid = al.findNearestUserMessage(validMessages, mid)
|
||||
|
||||
part1 := validMessages[:mid]
|
||||
part2 := validMessages[mid:]
|
||||
|
||||
|
|
@ -1558,18 +1567,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
|||
s1,
|
||||
s2,
|
||||
)
|
||||
resp, err := agent.Provider.Chat(
|
||||
ctx,
|
||||
[]providers.Message{{Role: "user", Content: mergePrompt}},
|
||||
nil,
|
||||
agent.Model,
|
||||
map[string]any{
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.3,
|
||||
"prompt_cache_key": agent.ID,
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
|
||||
resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
|
||||
if err == nil && resp.Content != "" {
|
||||
finalSummary = resp.Content
|
||||
} else {
|
||||
finalSummary = s1 + " " + s2
|
||||
|
|
@ -1589,6 +1589,68 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
|||
}
|
||||
}
|
||||
|
||||
// findNearestUserMessage finds the nearest user message to the given index.
|
||||
// It searches backward first, then forward if no user message is found.
|
||||
func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int {
|
||||
originalMid := mid
|
||||
|
||||
for mid > 0 && messages[mid].Role != "user" {
|
||||
mid--
|
||||
}
|
||||
|
||||
if messages[mid].Role == "user" {
|
||||
return mid
|
||||
}
|
||||
|
||||
mid = originalMid
|
||||
for mid < len(messages) && messages[mid].Role != "user" {
|
||||
mid++
|
||||
}
|
||||
|
||||
if mid < len(messages) {
|
||||
return mid
|
||||
}
|
||||
|
||||
return originalMid
|
||||
}
|
||||
|
||||
// retryLLMCall calls the LLM with retry logic.
|
||||
func (al *AgentLoop) retryLLMCall(
|
||||
ctx context.Context,
|
||||
agent *AgentInstance,
|
||||
prompt string,
|
||||
maxRetries int,
|
||||
) (*providers.LLMResponse, error) {
|
||||
const (
|
||||
llmTemperature = 0.3
|
||||
)
|
||||
|
||||
var resp *providers.LLMResponse
|
||||
var err error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
resp, err = agent.Provider.Chat(
|
||||
ctx,
|
||||
[]providers.Message{{Role: "user", Content: prompt}},
|
||||
nil,
|
||||
agent.Model,
|
||||
map[string]any{
|
||||
"max_tokens": agent.MaxTokens,
|
||||
"temperature": llmTemperature,
|
||||
"prompt_cache_key": agent.ID,
|
||||
},
|
||||
)
|
||||
if err == nil && resp != nil && resp.Content != "" {
|
||||
return resp, nil
|
||||
}
|
||||
if attempt < maxRetries-1 {
|
||||
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// summarizeBatch summarizes a batch of messages.
|
||||
func (al *AgentLoop) summarizeBatch(
|
||||
ctx context.Context,
|
||||
|
|
@ -1596,6 +1658,13 @@ func (al *AgentLoop) summarizeBatch(
|
|||
batch []providers.Message,
|
||||
existingSummary string,
|
||||
) (string, error) {
|
||||
const (
|
||||
llmMaxRetries = 3
|
||||
llmTemperature = 0.3
|
||||
fallbackMinContentLength = 200
|
||||
fallbackMaxContentPercent = 10
|
||||
)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(
|
||||
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
|
||||
|
|
@ -1611,21 +1680,40 @@ func (al *AgentLoop) summarizeBatch(
|
|||
}
|
||||
prompt := sb.String()
|
||||
|
||||
response, err := agent.Provider.Chat(
|
||||
ctx,
|
||||
[]providers.Message{{Role: "user", Content: prompt}},
|
||||
nil,
|
||||
agent.Model,
|
||||
map[string]any{
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.3,
|
||||
"prompt_cache_key": agent.ID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries)
|
||||
if err == nil && response.Content != "" {
|
||||
return strings.TrimSpace(response.Content), nil
|
||||
}
|
||||
return response.Content, nil
|
||||
|
||||
var fallback strings.Builder
|
||||
fallback.WriteString("Conversation summary: ")
|
||||
for i, m := range batch {
|
||||
if i > 0 {
|
||||
fallback.WriteString(" | ")
|
||||
}
|
||||
content := strings.TrimSpace(m.Content)
|
||||
runes := []rune(content)
|
||||
if len(runes) == 0 {
|
||||
fallback.WriteString(fmt.Sprintf("%s: ", m.Role))
|
||||
continue
|
||||
}
|
||||
|
||||
keepLength := len(runes) * fallbackMaxContentPercent / 100
|
||||
if keepLength < fallbackMinContentLength {
|
||||
keepLength = fallbackMinContentLength
|
||||
}
|
||||
|
||||
if keepLength > len(runes) {
|
||||
keepLength = len(runes)
|
||||
}
|
||||
|
||||
content = string(runes[:keepLength])
|
||||
if keepLength < len(runes) {
|
||||
content += "..."
|
||||
}
|
||||
fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content))
|
||||
}
|
||||
return fallback.String(), nil
|
||||
}
|
||||
|
||||
// estimateTokens estimates the number of tokens in a message list.
|
||||
|
|
@ -1644,6 +1732,7 @@ func (al *AgentLoop) handleCommand(
|
|||
ctx context.Context,
|
||||
msg bus.InboundMessage,
|
||||
agent *AgentInstance,
|
||||
opts *processOptions,
|
||||
) (string, bool) {
|
||||
if !commands.HasCommandPrefix(msg.Content) {
|
||||
return "", false
|
||||
|
|
@ -1653,7 +1742,7 @@ func (al *AgentLoop) handleCommand(
|
|||
return "", false
|
||||
}
|
||||
|
||||
rt := al.buildCommandsRuntime(agent)
|
||||
rt := al.buildCommandsRuntime(agent, opts)
|
||||
executor := commands.NewExecutor(al.cmdRegistry, rt)
|
||||
|
||||
var commandReply string
|
||||
|
|
@ -1682,7 +1771,7 @@ func (al *AgentLoop) handleCommand(
|
|||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtime {
|
||||
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
|
||||
rt := &commands.Runtime{
|
||||
Config: al.cfg,
|
||||
ListAgentIDs: al.registry.ListAgentIDs,
|
||||
|
|
@ -1712,6 +1801,20 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtim
|
|||
agent.Model = value
|
||||
return oldModel, nil
|
||||
}
|
||||
|
||||
rt.ClearHistory = func() error {
|
||||
if opts == nil {
|
||||
return fmt.Errorf("process options not available")
|
||||
}
|
||||
if agent.Sessions == nil {
|
||||
return fmt.Errorf("sessions not initialized for agent")
|
||||
}
|
||||
|
||||
agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0))
|
||||
agent.Sessions.SetSummary(opts.SessionKey, "")
|
||||
agent.Sessions.Save(opts.SessionKey)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,5 +12,6 @@ func BuiltinDefinitions() []Definition {
|
|||
listCommand(),
|
||||
switchCommand(),
|
||||
checkCommand(),
|
||||
clearCommand(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
pkg/commands/cmd_clear.go
Normal file
20
pkg/commands/cmd_clear.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package commands
|
||||
|
||||
import "context"
|
||||
|
||||
func clearCommand() Definition {
|
||||
return Definition{
|
||||
Name: "clear",
|
||||
Description: "Clear the chat history",
|
||||
Usage: "/clear",
|
||||
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||
if rt == nil || rt.ClearHistory == nil {
|
||||
return req.Reply(unavailableMsg)
|
||||
}
|
||||
if err := rt.ClearHistory(); err != nil {
|
||||
return req.Reply("Failed to clear chat history: " + err.Error())
|
||||
}
|
||||
return req.Reply("Chat history cleared!")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -13,4 +13,5 @@ type Runtime struct {
|
|||
GetEnabledChannels func() []string
|
||||
SwitchModel func(value string) (oldModel string, err error)
|
||||
SwitchChannel func(value string) error
|
||||
ClearHistory func() error
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue