mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
fix(chats): show the network switcher and say why execution status is unknown
The Network row of the Tools menu could never show the Off, Registries and All switcher: the host stopped setting `applies` because isolation alone does not prove network-policy enforcement, and nothing else set it. Let the provider report enforcement in its typed execution status (`networkPolicyEnforced`). The host sets `applies` only when that report is true and the status is available and isolated. The Sandbox reports it for the gVisor and KVM backends, whose guests reach the network only through the proxy that asks the host. Providers that do not report it keep the "not enforced" text. When the host cannot read the status, give the unknown state a reason (`unknown_reason`) in the API and the Tools menu: owner unreachable, not reported, stale, invalid, not selected, no answer or reported unknown. Log the class of each failure, rate limited. After a failure that a restart of the add-on owner can cause, let a long turn bind and read again, at most once every five seconds, instead of keeping the first failure for the whole turn. The Sandbox now reports "unavailable" with a reason when it started without settings, and explains a backend it does not know. When the Sandbox document skills are hidden because the Sandbox is not known to be isolated, say so in the skill list, in the turn note and in the `load_skill` error, instead of "This chat has no skills now". Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
26 files changed
+833
-81
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -331,11 +332,16 @@ var _ = Describe("execution binding owner resolver", func() {
|
||||
value := request.Observe(ctx)
|
||||
Expect(string(value.Readiness)).To(Equal("available"))
|
||||
Expect(string(value.Isolation)).To(Equal("isolated"))
|
||||
Expect(explanation()).To(BeEmpty())
|
||||
Expect(explanation().Explanation).To(BeEmpty())
|
||||
Expect(request.Observe(ctx)).To(Equal(value))
|
||||
Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, []string{"renamed-runner", "alternate-worker"})).To(Succeed())
|
||||
Expect(string(request.Observe(ctx).Isolation)).To(Equal("unknown"))
|
||||
Expect(explanation().Failure).To(Equal(studio.UnknownStale))
|
||||
Expect(broker.Isolation(ctx, studio.AnonymousPrincipal, "renamed-runner")).To(BeEmpty())
|
||||
none, selectedNone, noneDetail := broker.NewExecutionObservation(ctx, studio.AnonymousPrincipal, claim.ConversationID)
|
||||
Expect(none).To(BeNil())
|
||||
Expect(selectedNone).To(BeEmpty())
|
||||
Expect(noneDetail().Failure).To(Equal(studio.UnknownNotReported))
|
||||
Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, nil)).To(Succeed())
|
||||
fresh, selected, _ := broker.NewExecutionObservation(ctx, studio.AnonymousPrincipal, claim.ConversationID)
|
||||
Expect(fresh).NotTo(BeNil())
|
||||
@@ -439,3 +445,26 @@ var _ = Describe("workspace operation declarations", func() {
|
||||
Entry("similar prefix", []string{"workspaces.read", "workspace-other.read", "Workspace.read", "other.workspace.read"}, false),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("why an execution status could not be read", func() {
|
||||
DescribeTable("names the reason", func(stage string, err error, want studio.UnknownReason) {
|
||||
Expect(executionFailureReason(stage, err)).To(Equal(want))
|
||||
},
|
||||
Entry("owner down", "read", gateway.ErrUnavailable, studio.UnknownOwnerUnreachable),
|
||||
Entry("owner not sent to", "read", gateway.ErrNotSent, studio.UnknownOwnerUnreachable),
|
||||
Entry("owner down while binding", "binding", gateway.ErrUnavailable, studio.UnknownOwnerUnreachable),
|
||||
Entry("carrier error", "read", errors.New("carrier"), studio.UnknownOwnerUnreachable),
|
||||
Entry("no status tool bound", "binding", gateway.ErrForbidden, studio.UnknownNotReported),
|
||||
Entry("binding mismatch on read", "read", gateway.ErrForbidden, studio.UnknownStale),
|
||||
Entry("binding changed on validate", "validate", gateway.ErrForbidden, studio.UnknownStale),
|
||||
Entry("payload refused", "decode", errors.New("invalid execution readiness"), studio.UnknownInvalid),
|
||||
Entry("no answer in time", "read", context.DeadlineExceeded, studio.UnknownNoAnswer),
|
||||
)
|
||||
It("logs each reason at most once a minute", func() {
|
||||
t := time.Now()
|
||||
Expect(failureLogDue("test-reason", t)).To(BeTrue())
|
||||
Expect(failureLogDue("test-reason", t.Add(failureLogEvery/2))).To(BeFalse())
|
||||
Expect(failureLogDue("other-reason", t.Add(failureLogEvery/2))).To(BeTrue())
|
||||
Expect(failureLogDue("test-reason", t.Add(failureLogEvery))).To(BeTrue())
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,67 +16,169 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/studio/executionrequest"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
func (l lateStudioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() string) {
|
||||
func (l lateStudioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() studio.ExecutionDetail) {
|
||||
if p, ok := l.a.StudioAddonTools().(studio.ExecutionObserver); ok {
|
||||
return p.NewExecutionObservation(ctx, user, conversation)
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
func (s *studioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() string) {
|
||||
func (s *studioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() studio.ExecutionDetail) {
|
||||
var mu sync.Mutex
|
||||
var detail studio.ExecutionDetail
|
||||
replied := false
|
||||
var addonID string
|
||||
// fail records why no status could be read. The first failure wins: later
|
||||
// ones are consequences of it.
|
||||
fail := func(stage string, err error) {
|
||||
reason := executionFailureReason(stage, err)
|
||||
mu.Lock()
|
||||
first := detail.Failure == ""
|
||||
if first {
|
||||
detail.Failure = reason
|
||||
}
|
||||
mu.Unlock()
|
||||
if first && !errors.Is(err, context.Canceled) {
|
||||
logExecutionFailure(stage, reason, err, addonID, conversation)
|
||||
}
|
||||
}
|
||||
current := func() studio.ExecutionDetail {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
d := detail
|
||||
if d.Failure == "" && !replied {
|
||||
d.Failure = studio.UnknownNoAnswer
|
||||
}
|
||||
return d
|
||||
}
|
||||
actor, err := s.actor(user)
|
||||
if err != nil {
|
||||
return nil, "", nil
|
||||
fail("binding", err)
|
||||
return nil, "", current
|
||||
}
|
||||
b, err := s.adapter.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: conversation})
|
||||
if err != nil {
|
||||
return nil, "", nil
|
||||
fail("binding", err)
|
||||
return nil, "", current
|
||||
}
|
||||
var id [24]byte
|
||||
if _, err = rand.Read(id[:]); err != nil {
|
||||
return nil, "", nil
|
||||
fail("binding", err)
|
||||
return nil, "", current
|
||||
}
|
||||
addonID = b.AddonID
|
||||
q := model.ObservationID(hex.EncodeToString(id[:]))
|
||||
// Keep actor and conversation identity explicit. IO retains the original DTO.
|
||||
mb := model.Binding{Actor: actor.UserID, Credential: actor.CredentialID, Conversation: b.ConversationID, Mode: actor.Mode, Addon: b.AddonID, Generation: strconv.FormatUint(b.Generation, 10), Incarnation: b.Incarnation, ExecutionTool: b.ExecutionTool, StatusTool: b.StatusTool}
|
||||
var mu sync.Mutex
|
||||
explanation := ""
|
||||
io := executionrequest.IO{
|
||||
Validate: func(ctx context.Context, current model.Binding) error {
|
||||
if current != mb {
|
||||
fail("validate", gateway.ErrForbidden)
|
||||
return gateway.ErrForbidden
|
||||
}
|
||||
return s.adapter.ValidateExecutionBinding(ctx, actor, b)
|
||||
if err := s.adapter.ValidateExecutionBinding(ctx, actor, b); err != nil {
|
||||
fail("validate", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Read: func(ctx context.Context, current model.Binding, observation model.ObservationID, deadline time.Time) (model.Payload, error) {
|
||||
if current != mb || observation != q {
|
||||
fail("read", gateway.ErrForbidden)
|
||||
return model.InvalidPayload(), gateway.ErrForbidden
|
||||
}
|
||||
ctx, cancel := context.WithDeadline(ctx, deadline)
|
||||
defer cancel()
|
||||
reply, err := s.adapter.ExecutionStatusRead(ctx, actor, gateway.ExecutionStatusRequest{Version: 1, Binding: b, ObservationID: string(q)})
|
||||
if err != nil {
|
||||
fail("read", err)
|
||||
return model.InvalidPayload(), err
|
||||
}
|
||||
if reply.Version != 1 || reply.Binding != b || reply.ObservationID != string(q) {
|
||||
fail("read", gateway.ErrForbidden)
|
||||
return model.InvalidPayload(), gateway.ErrForbidden
|
||||
}
|
||||
raw, err := json.Marshal(reply.Status)
|
||||
if err != nil {
|
||||
fail("decode", err)
|
||||
return model.InvalidPayload(), err
|
||||
}
|
||||
status, err := contract.DecodeExecutionStatus(raw)
|
||||
if err != nil {
|
||||
fail("decode", err)
|
||||
return model.InvalidPayload(), err
|
||||
}
|
||||
mu.Lock()
|
||||
explanation = status.Explanation
|
||||
detail.Explanation, detail.NetworkPolicyEnforced = status.Explanation, status.NetworkPolicyEnforced
|
||||
replied = true
|
||||
mu.Unlock()
|
||||
return model.ValidPayload(model.Value{Readiness: model.Readiness(status.Readiness), Isolation: model.Isolation(status.Isolation)}), nil
|
||||
},
|
||||
}
|
||||
request := executionrequest.New(ctx, mb, q, model.Eligible, io, nil)
|
||||
return request, b.AddonID, func() string { mu.Lock(); defer mu.Unlock(); return explanation }
|
||||
return request, b.AddonID, current
|
||||
}
|
||||
|
||||
// executionFailureReason maps a failure to the reason the person is told. The
|
||||
// gateway answers forbidden for every refusal, so the stage tells them apart:
|
||||
// while binding, the chat has no add-on that reports a status; later, what was
|
||||
// bound no longer matches.
|
||||
func executionFailureReason(stage string, err error) studio.UnknownReason {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return studio.UnknownNoAnswer
|
||||
case errors.Is(err, gateway.ErrUnavailable):
|
||||
return studio.UnknownOwnerUnreachable
|
||||
case errors.Is(err, gateway.ErrForbidden):
|
||||
if stage == "binding" {
|
||||
return studio.UnknownNotReported
|
||||
}
|
||||
return studio.UnknownStale
|
||||
case stage == "decode":
|
||||
return studio.UnknownInvalid
|
||||
}
|
||||
return studio.UnknownOwnerUnreachable
|
||||
}
|
||||
|
||||
// failureLogEvery bounds the log line per reason: a chat page asks for the
|
||||
// status often and a broken add-on would fill the log.
|
||||
const failureLogEvery = time.Minute
|
||||
|
||||
var failureLog = struct {
|
||||
sync.Mutex
|
||||
last map[studio.UnknownReason]time.Time
|
||||
}{last: map[studio.UnknownReason]time.Time{}}
|
||||
|
||||
// failureLogDue says whether a failure of this reason may be logged at now,
|
||||
// and counts it if so.
|
||||
func failureLogDue(reason studio.UnknownReason, now time.Time) bool {
|
||||
failureLog.Lock()
|
||||
defer failureLog.Unlock()
|
||||
if last, ok := failureLog.last[reason]; ok && now.Sub(last) < failureLogEvery {
|
||||
return false
|
||||
}
|
||||
failureLog.last[reason] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// logExecutionFailure logs the class of a failure, never its text, which may
|
||||
// carry add-on or user data.
|
||||
func logExecutionFailure(stage string, reason studio.UnknownReason, err error, addon, conversation string) {
|
||||
if !failureLogDue(reason, time.Now()) {
|
||||
return
|
||||
}
|
||||
class := "other"
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
class = "deadline"
|
||||
case errors.Is(err, gateway.ErrUnavailable):
|
||||
class = "unavailable"
|
||||
case errors.Is(err, gateway.ErrForbidden):
|
||||
class = "forbidden"
|
||||
}
|
||||
xlog.Warn("Chats: the execution status could not be read", "stage", stage, "reason", string(reason), "class", class,
|
||||
"addon", addon, "chat", conversation[:min(len(conversation), 8)])
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func writeAddonList(c echo.Context, list []studiosvc.AddonInfo, cmds studiosvc.C
|
||||
CommandsIsolation: cmds.Isolation, CommandsProvider: cmds.Provider, CommandsConsent: cmds.Consent,
|
||||
CommandsFallback: cmds.Fallback, CommandsFallbackReason: cmds.FallbackReason}
|
||||
if v := cmds.ExecutionStatus; v != nil {
|
||||
out.ExecutionStatus = &schema.ChatsExecutionStatus{Readiness: v.Readiness, Isolation: v.Isolation, Explanation: v.Explanation, Generation: v.Generation, Incarnation: v.Incarnation}
|
||||
out.ExecutionStatus = &schema.ChatsExecutionStatus{Readiness: v.Readiness, Isolation: v.Isolation, Explanation: v.Explanation, UnknownReason: v.UnknownReason, Generation: v.Generation, Incarnation: v.Incarnation}
|
||||
}
|
||||
for _, a := range list {
|
||||
out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off})
|
||||
|
||||
@@ -123,6 +123,23 @@ test.describe('Chat that cannot run commands', () => {
|
||||
await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('an unknown status says why the host could not read it', async ({ page }) => {
|
||||
await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 1, off: false }], commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: 'owner_unreachable' } }))
|
||||
await page.goto('/app/chats/c1')
|
||||
await page.getByTestId('chat-tools-button').click()
|
||||
const notice = page.getByTestId('chat-commands-notice')
|
||||
await expect(notice).toContainText('Execution readiness or isolation is unknown: the add-on owner could not be reached.')
|
||||
await shot(page, 'commands-unknown-reason')
|
||||
})
|
||||
|
||||
test('an unknown status without a reason keeps the generic text', async ({ page }) => {
|
||||
await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 1, off: false }], commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown' } }))
|
||||
await page.goto('/app/chats/c1')
|
||||
await page.getByTestId('chat-tools-button').click()
|
||||
await expect(page.getByTestId('chat-commands-notice')).toContainText('Execution readiness or isolation is unknown.')
|
||||
await expect(page.getByTestId('chat-commands-notice')).not.toContainText('add-on')
|
||||
})
|
||||
|
||||
test('a chat that runs commands shows neither the chip nor the notice', async ({ page }) => {
|
||||
await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 7, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated' }))
|
||||
await page.goto('/app/chats/c1')
|
||||
|
||||
@@ -30,7 +30,7 @@ const DETAIL = {
|
||||
id: 'c1', title: 'Packages', model: 'qwen3-8b', kind: 'chat', created: '2026-10-30T10:00:00Z', updated: '2026-10-30T10:00:00Z',
|
||||
running: false, last_turn: 'done', pending: [],
|
||||
}
|
||||
const sandbox = { addons: [{ id: 'sandbox', tools: 2, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated' }
|
||||
const sandbox = { addons: [{ id: 'sandbox', tools: 2, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated', execution_status: { readiness: 'available', isolation: 'isolated' } }
|
||||
const view = (over = {}) => ({
|
||||
preset: 'off', ceiling: 'registries', effective: 'off', applies: true, applies_reason: '', addon: 'sandbox',
|
||||
registries: [{ id: 'pypi', title: 'Python (PyPI)', hosts: ['pypi.org', 'files.pythonhosted.org'] }], allowed_hosts: [], rules: [], cards: [], ...over,
|
||||
@@ -154,6 +154,17 @@ test.describe('Network of the Sandbox commands', () => {
|
||||
await expect(page.getByTestId('chat-network')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('a runner that does not report enforcement shows the not-enforced line and no switcher', async ({ page }) => {
|
||||
// What the server sends when the isolated provider's status has no
|
||||
// network-policy enforcement report.
|
||||
await stubChat(page, { egress: view({ applies: false, applies_reason: 'none' }) })
|
||||
await page.goto('/app/chats/c1')
|
||||
await page.getByTestId('chat-tools-button').click()
|
||||
await expect(page.getByTestId('chat-network-unenforced')).toContainText('Network-policy enforcement is not verified')
|
||||
await expect(page.getByTestId('chat-network')).toHaveCount(0)
|
||||
await shot(page, 'network-not-enforced')
|
||||
})
|
||||
|
||||
test('with no add-on that draws the network controls, the Tools menu has no Network row', async ({ page }) => {
|
||||
await stubChat(page, { egress: view({ addon: undefined }) })
|
||||
await page.goto('/app/chats/c1')
|
||||
|
||||
@@ -412,6 +412,15 @@
|
||||
"hostChipTitle": "Commands in this chat run on this machine, not isolated. Select for details.",
|
||||
"providerHost": "Commands run on the server without isolation.",
|
||||
"unknown": "Execution readiness or isolation is unknown.",
|
||||
"unknownBy": {
|
||||
"owner_unreachable": "Execution readiness or isolation is unknown: the add-on owner could not be reached.",
|
||||
"not_reported": "Execution readiness or isolation is unknown: the add-on has not reported its execution status.",
|
||||
"stale": "Execution readiness or isolation is unknown: the status did not match the add-on's current version.",
|
||||
"invalid_status": "Execution readiness or isolation is unknown: the add-on sent a status the host could not accept.",
|
||||
"not_selected": "Execution readiness or isolation is unknown: the add-on is not the one that runs this chat's commands.",
|
||||
"no_answer": "Execution readiness or isolation is unknown: the add-on did not answer in time.",
|
||||
"reported_unknown": "Execution readiness or isolation is unknown: the add-on reported that it cannot tell."
|
||||
},
|
||||
"providerUnavailable": "The selected execution provider is unavailable.",
|
||||
"disabled": "Running commands is switched off for this chat."
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CAP_CHAT } from '../../utils/capabilities'
|
||||
import { seesImages } from '../../utils/chatVision'
|
||||
import { connectorRef } from '../../utils/chatMCP'
|
||||
import { networkMode } from '../../utils/chatEgress'
|
||||
import { unknownTextKey } from '../../utils/chatCommands'
|
||||
import { gbLabel, gbNumber } from '../../utils/modelLedger'
|
||||
import { useModelFit } from '../../hooks/useModelFit'
|
||||
import { useCommandsNotice } from '../../hooks/useCommandsNotice'
|
||||
@@ -122,7 +123,7 @@ export default function ChatComposer({
|
||||
if (!by || selected.provider !== by) return { chip, note: tc('tools.commands.unknown') }
|
||||
if (['host', 'host-off', 'host-unavailable', 'provider-host'].includes(selected.state)) return { chip, note: tc('tools.commands.providerHost'), warn: true }
|
||||
if (selected.state === 'available') return { chip, note: tc('tools.shell.isolated') }
|
||||
return { chip, note: tc('tools.commands.unknown') }
|
||||
return { chip, note: tc(`tools.commands.${unknownTextKey(selected.code)}`) }
|
||||
}
|
||||
|
||||
// What a row of the model list says: whether it is loaded, what it can do
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { commandsNotice } from '../utils/chatCommands'
|
||||
// Dismissal is deliberately mount-local: logout/remount never retains it.
|
||||
// Exact binding, observation and actor/chat context must match, not just state.
|
||||
export function useCommandsNotice(commands, isAdmin, context) {
|
||||
const info = commandsNotice(commands.state, { isAdmin, reason: commands.reason })
|
||||
const identity = JSON.stringify([context, commands.identity, commands.state, commands.reason])
|
||||
const info = commandsNotice(commands.state, { isAdmin, reason: commands.reason, code: commands.code })
|
||||
const identity = JSON.stringify([context, commands.identity, commands.state, commands.reason, commands.code])
|
||||
const [hiddenIdentity, setHiddenIdentity] = useState('')
|
||||
useEffect(() => { setHiddenIdentity('') }, [identity])
|
||||
const hide = useCallback(() => {
|
||||
|
||||
+14
-6
@@ -1,6 +1,14 @@
|
||||
// Command availability and isolation come from the selected provider DTO, not
|
||||
// an add-on's private discovery response or its name.
|
||||
|
||||
// Why the host has no execution status (execution_status.unknown_reason).
|
||||
// Each code has its own sentence under chats:tools.commands.unknownBy.
|
||||
const UNKNOWN_REASONS = ['owner_unreachable', 'not_reported', 'stale', 'invalid_status', 'not_selected', 'no_answer', 'reported_unknown']
|
||||
|
||||
// The text key of the notice of an unknown state, with the reason when known.
|
||||
export const unknownTextKey = (code) => (UNKNOWN_REASONS.includes(code) ? `unknownBy.${code}` : 'unknown')
|
||||
const reasonCode = (observation) => (UNKNOWN_REASONS.includes(observation?.unknown_reason) ? observation.unknown_reason : '')
|
||||
|
||||
// commandsState reads the commands fields of an add-on list as one state and
|
||||
// its reason.
|
||||
export function commandsState(res) {
|
||||
@@ -15,11 +23,11 @@ export function commandsState(res) {
|
||||
const observation = res?.execution_status
|
||||
if (state === 'available') {
|
||||
const isolation = observation?.isolation || res?.commands_isolation
|
||||
if (observation && observation.readiness !== 'available') return { state: 'unknown', reason: plainExplanation(observation.explanation) }
|
||||
if (observation && observation.readiness !== 'available') return { state: 'unknown', reason: plainExplanation(observation.explanation), code: reasonCode(observation) }
|
||||
if (isolation === 'host') return { state: 'provider-host', reason: plainExplanation(observation?.explanation) }
|
||||
if (isolation !== 'isolated') return { state: 'unknown', reason: plainExplanation(observation?.explanation) }
|
||||
if (isolation !== 'isolated') return { state: 'unknown', reason: plainExplanation(observation?.explanation), code: reasonCode(observation) }
|
||||
}
|
||||
if (state === 'none' && observation) return { state: 'unknown', reason: plainExplanation(observation.explanation) }
|
||||
if (state === 'none' && observation) return { state: 'unknown', reason: plainExplanation(observation.explanation), code: reasonCode(observation) }
|
||||
if (state === 'unavailable' && (observation || res?.commands_provider)) return { state: 'provider-unavailable', reason: plainExplanation(observation?.explanation || reason) }
|
||||
return { state, reason }
|
||||
}
|
||||
@@ -27,14 +35,14 @@ export function commandsState(res) {
|
||||
// The notice for a state, or null when commands run (or the state is unknown).
|
||||
// keys are under chats:tools.commands. link is for administrators only; a
|
||||
// person who is not one is told to ask one, unless the fix is theirs.
|
||||
export function commandsNotice(commands, { isAdmin = false, reason = '' } = {}) {
|
||||
export function commandsNotice(commands, { isAdmin = false, reason = '', code = '' } = {}) {
|
||||
switch (commands) {
|
||||
case 'provider-host':
|
||||
case 'unknown':
|
||||
case 'loading':
|
||||
case 'provider-unavailable':
|
||||
case 'switched_off':
|
||||
return { state: commands, titleKey: commands === 'provider-host' ? 'hostTitle' : 'title', textKey: commands === 'switched_off' ? 'disabled' : commands === 'provider-host' ? 'providerHost' : commands === 'provider-unavailable' ? 'providerUnavailable' : 'unknown', reason: plainExplanation(reason), tone: 'warn', link: null, askKey: null }
|
||||
return { state: commands, titleKey: commands === 'provider-host' ? 'hostTitle' : 'title', textKey: commands === 'switched_off' ? 'disabled' : commands === 'provider-host' ? 'providerHost' : commands === 'provider-unavailable' ? 'providerUnavailable' : unknownTextKey(code), reason: plainExplanation(reason), tone: 'warn', link: null, askKey: null }
|
||||
|
||||
case 'off':
|
||||
return { state: 'off', textKey: 'disabled', link: null, askKey: null, reason: '' }
|
||||
@@ -121,5 +129,5 @@ export function plainExplanation(value) {
|
||||
|
||||
export function noticeIdentity(res, chat, actor) {
|
||||
const v = res?.execution_status || {}
|
||||
return JSON.stringify([actor, chat, res?.commands_provider, v.generation, v.incarnation, res?.commands, v.readiness, v.isolation || res?.commands_isolation])
|
||||
return JSON.stringify([actor, chat, res?.commands_provider, v.generation, v.incarnation, res?.commands, v.readiness, v.isolation || res?.commands_isolation, v.unknown_reason])
|
||||
}
|
||||
@@ -269,7 +269,7 @@ for (const provider of ['copper-runner', 'violet-executor']) test(`typed safety:
|
||||
})
|
||||
const composer = hookHarness('../../src/components/chat/ChatComposer.jsx', 'ChatComposer', {
|
||||
useTranslation:() => ({t:key => key}), useAuth:() => ({isAdmin:true,user:{id:'alice'}}),
|
||||
useModelFit:() => ({}), useCommandsNotice:() => ({info:null}),
|
||||
useModelFit:() => ({}), useCommandsNotice:() => ({info:null}), unknownTextKey: imports.unknownTextKey,
|
||||
// Only the original code needs these; keeping them permits a genuine RED run.
|
||||
SANDBOX_ADDON:'sandbox', useSandboxStatus:() => null,
|
||||
}, source => source.replace('export default function', 'function').split(' // What a row of the model list')[0] + 'return shellNote\n}')
|
||||
@@ -303,3 +303,20 @@ for (const provider of ['copper-runner', 'violet-executor']) test(`typed safety:
|
||||
group.providers.bash = 'builtin'
|
||||
assert.equal(render('bob:3', {state:'host',provider:'builtin'}).warn, true)
|
||||
})
|
||||
|
||||
test('unknown status: each reason code has its own text key, and a code from a newer server falls back', async () => {
|
||||
const { commandsState, unknownTextKey } = await import('../../src/utils/chatCommands.js')
|
||||
const locale = JSON.parse((await import('node:fs')).readFileSync(new URL('../../public/locales/en/chats.json', import.meta.url), 'utf8'))
|
||||
for (const code of ['owner_unreachable', 'not_reported', 'stale', 'invalid_status', 'not_selected', 'no_answer', 'reported_unknown']) {
|
||||
const res = { commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: code } }
|
||||
const state = commandsState(res)
|
||||
assert.equal(state.state, 'unknown')
|
||||
assert.equal(state.code, code)
|
||||
const info = commandsNotice('unknown', { code: state.code })
|
||||
assert.equal(info.textKey, `unknownBy.${code}`)
|
||||
assert.ok(locale.tools.commands.unknownBy[code], `missing text for ${code}`)
|
||||
}
|
||||
assert.equal(unknownTextKey('from_the_future'), 'unknown')
|
||||
assert.equal(unknownTextKey(''), 'unknown')
|
||||
assert.equal(commandsState({ commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: 'x' } }).code, '')
|
||||
})
|
||||
+10
-4
@@ -1622,8 +1622,10 @@ type ChatsEgress struct {
|
||||
Preset string `json:"preset"` // off, registries, all
|
||||
Ceiling string `json:"ceiling"` // the administrator's limit
|
||||
Effective string `json:"effective"` // the lower of the two
|
||||
// Applies is false when commands run on this machine (applies_reason
|
||||
// host) or no Sandbox serves bash (none).
|
||||
// Applies is true when the add-on that runs the commands reports that it
|
||||
// enforces the network policy. It is false when commands run on this
|
||||
// machine (applies_reason host), or no Sandbox serves bash or the add-on
|
||||
// does not report enforcement (none).
|
||||
Applies bool `json:"applies"`
|
||||
AppliesReason string `json:"applies_reason,omitempty"`
|
||||
// Addon is the add-on that runs the chat's commands and carries the
|
||||
@@ -1697,6 +1699,10 @@ type ChatsExecutionStatus struct {
|
||||
Readiness string `json:"readiness"`
|
||||
Isolation string `json:"isolation"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Generation string `json:"generation,omitempty"`
|
||||
Incarnation string `json:"incarnation,omitempty"`
|
||||
// UnknownReason says why the status is unknown: owner_unreachable,
|
||||
// not_reported, stale, invalid_status, not_selected, no_answer or
|
||||
// reported_unknown. Absent when the status is known.
|
||||
UnknownReason string `json:"unknown_reason,omitempty" example:"owner_unreachable"`
|
||||
Generation string `json:"generation,omitempty"`
|
||||
Incarnation string `json:"incarnation,omitempty"`
|
||||
}
|
||||
@@ -61,6 +61,23 @@ type addonSkills struct {
|
||||
known []AddonSkillSet
|
||||
// pins are the administrator's choices, by name.
|
||||
pins map[string]string
|
||||
// hiddenDocuments are the document skills of the Sandbox left out because
|
||||
// it runs the chat's commands without isolation.
|
||||
hiddenDocuments []string
|
||||
// documentsWhy is why the Sandbox is not known to be isolated.
|
||||
documentsWhy string
|
||||
}
|
||||
|
||||
// withNotes adds to notes the line that says why the Sandbox document skills
|
||||
// are missing, so the person is not left to guess.
|
||||
// documentNotePrefix starts the note that explains the missing document skills.
|
||||
const documentNotePrefix = "the document skills "
|
||||
|
||||
func (a addonSkills) withNotes(notes []string) []string {
|
||||
if len(a.hiddenDocuments) == 0 {
|
||||
return notes
|
||||
}
|
||||
return append(notes, documentNotePrefix+strings.Join(a.hiddenDocuments, ", ")+" need an isolated Sandbox; "+a.documentsWhy)
|
||||
}
|
||||
|
||||
// addonSkillsFor lists the add-ons of the user that ship skills and decides
|
||||
@@ -102,6 +119,10 @@ func (s *Service) addonSkillsFor(ctx context.Context, p string, addonsOff []stri
|
||||
continue
|
||||
}
|
||||
if set.AddonID == "sandbox" && !sandboxDocumentEligible(command) {
|
||||
if command.State == CommandsAvailable && command.Provider == "sandbox" {
|
||||
out.documentsWhy = s.documentsWhy(ctx, p, command)
|
||||
out.hiddenDocuments = append(out.hiddenDocuments, slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return !contract.DocumentSkill(n) })...)
|
||||
}
|
||||
set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), contract.DocumentSkill)
|
||||
set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), contract.DocumentSkill)
|
||||
}
|
||||
@@ -217,3 +238,23 @@ func (a addonSkills) markFallbacks(list []skills.Skill) string {
|
||||
}
|
||||
return "The built-in skills " + strings.Join(parts, ", ") + " serve because the add-on's own skill is off or not running in this chat."
|
||||
}
|
||||
|
||||
// documentsWhy says why the Sandbox that runs the chat's commands is not known
|
||||
// to be isolated: it runs them on the host, or its status could not be read.
|
||||
func (s *Service) documentsWhy(ctx context.Context, p string, command Commands) string {
|
||||
if command.Isolation == "host" {
|
||||
return "its commands run on the server without isolation"
|
||||
}
|
||||
value, d := ObserveExecution(ctx, p, command.Addon)
|
||||
if value.Readiness == "unavailable" {
|
||||
return "the Sandbox reports that it cannot run commands"
|
||||
}
|
||||
why := d.Failure.Text()
|
||||
if d.Failure == "" {
|
||||
why = "its status says isolation is unknown"
|
||||
}
|
||||
if d.Explanation != "" {
|
||||
why += " (" + d.Explanation + ")"
|
||||
}
|
||||
return "its status could not be read: " + why
|
||||
}
|
||||
@@ -243,7 +243,7 @@ type presentationAddons struct {
|
||||
bound bool
|
||||
}
|
||||
|
||||
func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() string) {
|
||||
func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() studio.ExecutionDetail) {
|
||||
if !p.bound {
|
||||
return nil, "", nil
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, c
|
||||
},
|
||||
}, nil)
|
||||
Expect(r.Binding()).To(Equal(b))
|
||||
return r, b.Addon, func() string { return "plain explanation" }
|
||||
return r, b.Addon, func() studio.ExecutionDetail { return studio.ExecutionDetail{Explanation: "plain explanation"} }
|
||||
}
|
||||
|
||||
var _ = Describe("execution presentation DTO", func() {
|
||||
|
||||
@@ -28,6 +28,30 @@ var _ = Describe("document command eligibility", func() {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(skills.Names(skills.Usable(list))).NotTo(ContainElement("docx"))
|
||||
})
|
||||
DescribeTable("tells the person why the Sandbox document skills are missing", func(command Commands, off []string, wantNote bool) {
|
||||
ctx := context.Background()
|
||||
b := &skillBroker{}
|
||||
svc, f := newSkillService(b)
|
||||
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{
|
||||
"docx/SKILL.md": {Data: md("docx", "Sandbox document.")},
|
||||
"pdf/SKILL.md": {Data: md("pdf", "Sandbox pdf.")},
|
||||
"plot/SKILL.md": {Data: md("plot", "Sandbox plots.")},
|
||||
})).To(Succeed())
|
||||
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx", "pdf", "plot"}, Shadows: []string{"docx", "pdf"}, Running: true})
|
||||
_, notes, err := chatSkillNotes(svc, ctx, off, command)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
if wantNote {
|
||||
Expect(notes).To(ContainElement(HavePrefix("the document skills docx, pdf need an isolated Sandbox; ")))
|
||||
} else {
|
||||
Expect(strings.Join(notes, ";")).NotTo(ContainSubstring("isolated Sandbox"))
|
||||
}
|
||||
},
|
||||
Entry("commands run on the host", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "host"}, nil, true),
|
||||
Entry("isolation unknown", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox"}, nil, true),
|
||||
Entry("isolated", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, false),
|
||||
Entry("another provider runs the commands", Commands{State: CommandsAvailable, Addon: "other", Provider: "other", Isolation: "host"}, nil, false),
|
||||
Entry("the Sandbox is off for the chat", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "host"}, []string{"sandbox"}, false),
|
||||
)
|
||||
It("does not switch a missing pinned document provider to Office", func() {
|
||||
a := addonSkills{pins: map[string]string{"docx": "sandbox"}, usable: []AddonSkillSet{{AddonID: "office", Shadows: []string{"docx"}}}}
|
||||
id, chosen := a.provider("docx")
|
||||
@@ -174,6 +198,40 @@ var _ = Describe("document provider matrix", func() {
|
||||
Expect(saved.Skills).To(Equal([]string{"docx"}))
|
||||
}, Entry("permitted then revoked Sandbox", true), Entry("absent Sandbox grant", false))
|
||||
|
||||
It("says why a document skill is missing when the status cannot be read after the tool was offered", func() {
|
||||
ctx := context.Background()
|
||||
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
|
||||
svc, f := newSkillService(b.skillBroker)
|
||||
svc.d.AddonTools = b
|
||||
agentsStore, err := agents.NewJSON("")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
svc.d.Agents = agentsStore
|
||||
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"pptx/SKILL.md": {Data: skillMD("pptx", "Sandbox slides.", "Slides body.", "requires: [exec]")}})).To(Succeed())
|
||||
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"pptx"}, Shadows: []string{"pptx"}, Running: true})
|
||||
agent, err := svc.SaveAgent(ctx, "alice", agents.Agent{Name: "Slides", Model: "m", Addons: []string{"sandbox"}, Skills: []string{"pptx"}})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
parent, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
rec := session.ChatRecord{ID: "slides-child", Principal: "alice", Model: "m", Kind: session.KindSubAgent, ParentID: parent.ID, AgentID: agent.ID}
|
||||
Expect(svc.d.Store.CreateChat(ctx, rec)).To(Succeed())
|
||||
x, err := svc.ChatAccess(ctx, "alice", rec.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
turn, err := svc.chatTools(ctx, x)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
var loader session.Tool
|
||||
for _, tool := range turn.Tools {
|
||||
if tool.Name == "load_skill" {
|
||||
loader = tool
|
||||
}
|
||||
}
|
||||
Expect(loader.Run).NotTo(BeNil(), "the tool is offered while the status reads as isolated")
|
||||
// The status can no longer be read when the model calls the tool.
|
||||
b.isolation = ""
|
||||
_, err = loader.Run(ctx, json.RawMessage(`{"name":"pptx"}`))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).NotTo(ContainSubstring("has no skills now"))
|
||||
Expect(err.Error()).To(ContainSubstring("need an isolated Sandbox; its status could not be read"))
|
||||
})
|
||||
It("opens a scoped chat with load_skill without reentering session access resolution", func() {
|
||||
ctx := context.Background()
|
||||
// A stub harness: the spec checks that Open builds the chat tools
|
||||
@@ -304,3 +362,10 @@ func documentCommands(s *Service, ctx context.Context, p string, rec session.Cha
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return c
|
||||
}
|
||||
|
||||
// chatSkillNotes resolves the skills of a chat as the turn does and returns
|
||||
// the notes it gets.
|
||||
func chatSkillNotes(svc *Service, ctx context.Context, off []string, command Commands) ([]skills.Skill, []string, error) {
|
||||
list, notes, _, err := svc.chatSkillSet(ctx, "alice", nil, off, nil, command)
|
||||
return list, notes, err
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/egress"
|
||||
"github.com/mudler/LocalAI/core/services/studio/egress/audit"
|
||||
"github.com/mudler/LocalAI/core/services/studio/executionrequest"
|
||||
"github.com/mudler/LocalAI/core/services/studio/store"
|
||||
"github.com/mudler/LocalAI/core/services/studio/studiotest"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -222,6 +224,73 @@ func (c controlledAddons) Controls(_ context.Context, _, addonID string) []strin
|
||||
return c.controls[addonID]
|
||||
}
|
||||
|
||||
// reportingAddons is a broker whose runner reports its typed execution status.
|
||||
type reportingAddons struct {
|
||||
controlledAddons
|
||||
status model.Value
|
||||
// enforced is what the runner says about the network policy.
|
||||
enforced bool
|
||||
}
|
||||
|
||||
func (r reportingAddons) NewExecutionObservation(ctx context.Context, user, _ string) (*executionrequest.Request, string, func() studio.ExecutionDetail) {
|
||||
b := model.Binding{Actor: user, Addon: "sandbox", Generation: "1", Incarnation: "one", ExecutionTool: "bash", StatusTool: "status"}
|
||||
req := executionrequest.New(ctx, b, "q", model.Eligible, executionrequest.IO{
|
||||
Validate: func(context.Context, model.Binding) error { return nil },
|
||||
Read: func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
|
||||
return model.ValidPayload(r.status), nil
|
||||
},
|
||||
}, nil)
|
||||
return req, b.Addon, func() studio.ExecutionDetail { return studio.ExecutionDetail{NetworkPolicyEnforced: r.enforced} }
|
||||
}
|
||||
|
||||
func (r reportingAddons) Isolation(ctx context.Context, user, addonID string) string {
|
||||
v, _ := studio.ExecutionObservation(ctx, user, addonID)
|
||||
if v.Readiness != model.Available {
|
||||
return ""
|
||||
}
|
||||
return string(v.Isolation)
|
||||
}
|
||||
|
||||
var _ = Describe("whether the network settings apply to the commands of a chat", func() {
|
||||
egressOf := func(status model.Value, enforced bool, controls ...string) studio.EgressView {
|
||||
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
|
||||
f := newFiles()
|
||||
svc, _ := newToolService(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, func(d *studio.Deps) {
|
||||
d.Files = f
|
||||
d.AddonTools = reportingAddons{controlledAddons{isolatedAddons{fa, "isolated"}, map[string][]string{"sandbox": controls}}, status, enforced}
|
||||
})
|
||||
v, err := svc.Egress(context.Background(), alice, create(svc, alice))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return v
|
||||
}
|
||||
isolated := model.Value{Readiness: model.Available, Isolation: model.Isolated}
|
||||
|
||||
It("applies when the isolated runner reports that it enforces the policy", func() {
|
||||
v := egressOf(isolated, true, "network-policy")
|
||||
Expect(v.Applies).To(BeTrue())
|
||||
Expect(v.AppliesReason).To(BeEmpty())
|
||||
Expect(v.JSON().Addon).To(Equal("sandbox"))
|
||||
})
|
||||
It("does not apply when the runner does not report enforcement", func() {
|
||||
v := egressOf(isolated, false, "network-policy")
|
||||
Expect(v.Applies).To(BeFalse())
|
||||
Expect(v.AppliesReason).To(Equal(studio.EgressAppliesNone))
|
||||
})
|
||||
It("does not trust an enforcement report from a runner that is not isolated", func() {
|
||||
v := egressOf(model.Value{Readiness: model.Available, Isolation: model.Host}, true, "network-policy")
|
||||
Expect(v.Applies).To(BeFalse())
|
||||
})
|
||||
It("does not trust an enforcement report from a runner that is not available", func() {
|
||||
v := egressOf(model.Value{Readiness: model.Unavailable}, true, "network-policy")
|
||||
Expect(v.Applies).To(BeFalse())
|
||||
})
|
||||
It("applies but draws no row without the network-policy control", func() {
|
||||
v := egressOf(isolated, true, "tool-providers")
|
||||
Expect(v.Applies).To(BeTrue())
|
||||
Expect(v.JSON().Addon).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("the add-on that draws the network row of a chat", func() {
|
||||
runner := func(addonID string) []studio.AddonTool {
|
||||
bash := addonTool(addonID, "bash")
|
||||
|
||||
@@ -28,19 +28,21 @@ var (
|
||||
// The reasons the network settings do not apply to a chat.
|
||||
const (
|
||||
EgressAppliesHost = "host" // commands run on this machine, with its network
|
||||
EgressAppliesNone = "none" // no Sandbox serves bash in this chat
|
||||
EgressAppliesNone = "none" // no Sandbox serves bash, or it does not report that it enforces the policy
|
||||
)
|
||||
|
||||
// EgressView is the network of one chat, as its network panel shows it.
|
||||
type EgressView struct {
|
||||
Preset, Ceiling, Effective egress.Preset
|
||||
// Applies is false when commands run on this machine or no Sandbox serves
|
||||
// bash; AppliesReason then says which.
|
||||
// Applies is true when the provider that runs the commands reports that it
|
||||
// enforces the network policy. It is false when commands run on this
|
||||
// machine, no Sandbox serves bash, or the provider does not report
|
||||
// enforcement; AppliesReason then says which.
|
||||
Applies bool
|
||||
AppliesReason string
|
||||
// Addon is the add-on that runs the chat's commands and carries the
|
||||
// network-policy host control, which draws the network row of the chat:
|
||||
// set only while Applies. Without it no add-on draws the controls, and
|
||||
// set only for a provider that carries it. Without it no add-on draws the controls, and
|
||||
// the policy still holds.
|
||||
Addon string
|
||||
Registries []egress.Registry
|
||||
@@ -118,8 +120,13 @@ func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, er
|
||||
case cmds.State == CommandsAvailable && (cmds.Provider == BuiltinProvider || cmds.Isolation == "host"):
|
||||
v.AppliesReason = EgressAppliesHost
|
||||
case cmds.State == CommandsAvailable:
|
||||
// Provider isolation does not establish host network-policy enforcement.
|
||||
v.AppliesReason = EgressAppliesNone
|
||||
// Isolation alone does not establish network-policy enforcement: the
|
||||
// settings apply only when the provider reports that it enforces them
|
||||
// in a status that is available and isolated.
|
||||
v.Applies = cmds.ExecutionStatus != nil && cmds.ExecutionStatus.NetworkPolicyEnforced
|
||||
if !v.Applies {
|
||||
v.AppliesReason = EgressAppliesNone
|
||||
}
|
||||
if c, ok := s.d.AddonTools.(AddonControls); ok && slices.Contains(c.Controls(ctx, p, cmds.Provider), contract.ControlNetworkPolicy) {
|
||||
v.Addon = cmds.Provider
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/executionrequest"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
@@ -13,18 +15,59 @@ import (
|
||||
// ExecutionObserver admits the owner-selected provider, not a discovery claim.
|
||||
// The request and explanation reader belong exclusively to this outer scope.
|
||||
type ExecutionObserver interface {
|
||||
NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() string)
|
||||
// The last result reads what the provider reported beside the typed value,
|
||||
// or why there is none. It is never nil for a nil request that failed.
|
||||
NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() ExecutionDetail)
|
||||
}
|
||||
|
||||
// UnknownReason says why the host has no execution status for a chat. The
|
||||
// codes reach the API and the chat page, which words them.
|
||||
type UnknownReason string
|
||||
|
||||
const (
|
||||
// UnknownOwnerUnreachable: the process that owns the add-on did not answer.
|
||||
UnknownOwnerUnreachable UnknownReason = "owner_unreachable"
|
||||
// UnknownNotReported: the add-on declares no execution status to read.
|
||||
UnknownNotReported UnknownReason = "not_reported"
|
||||
// UnknownStale: the add-on changed (version or runtime) while it was read.
|
||||
UnknownStale UnknownReason = "stale"
|
||||
// UnknownInvalid: the add-on answered with a status the host refuses.
|
||||
UnknownInvalid UnknownReason = "invalid_status"
|
||||
// UnknownNotSelected: the add-on does not run this chat's commands.
|
||||
UnknownNotSelected UnknownReason = "not_selected"
|
||||
// UnknownNoAnswer: the add-on did not answer in time.
|
||||
UnknownNoAnswer UnknownReason = "no_answer"
|
||||
// UnknownReportedUnknown: the add-on answered that it cannot tell.
|
||||
UnknownReportedUnknown UnknownReason = "reported_unknown"
|
||||
)
|
||||
|
||||
// ExecutionDetail is what the provider said beside the typed value, plus why
|
||||
// no status could be read. Failure is meaningful only while the value is
|
||||
// unknown.
|
||||
type ExecutionDetail struct {
|
||||
Explanation string
|
||||
// NetworkPolicyEnforced is the provider's report; the host trusts it only
|
||||
// for an available, isolated value.
|
||||
NetworkPolicyEnforced bool
|
||||
Failure UnknownReason
|
||||
}
|
||||
|
||||
type executionObservationKey struct{}
|
||||
type executionObservation struct {
|
||||
ctx context.Context
|
||||
user, conversation string
|
||||
once sync.Once
|
||||
request *executionrequest.Request
|
||||
addon string
|
||||
explanation func() string
|
||||
provider ExecutionObserver
|
||||
// mu guards the fields below. A scope that is closed never starts again.
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
started bool
|
||||
attempted time.Time
|
||||
// retriable is set when the last observation failed in a way a fresh one
|
||||
// may fix (the owner restarted or did not answer).
|
||||
retriable bool
|
||||
request *executionrequest.Request
|
||||
addon string
|
||||
detail func() ExecutionDetail
|
||||
provider ExecutionObserver
|
||||
}
|
||||
|
||||
// executionScope borrows existing outer state. Nested consumers must not close
|
||||
@@ -38,7 +81,9 @@ func (s *Service) executionScope(ctx context.Context, user, conversation string)
|
||||
scope := &executionObservation{ctx: ownerCtx, user: user, conversation: conversation, provider: provider}
|
||||
return context.WithValue(ctx, executionObservationKey{}, scope), func() {
|
||||
cancel()
|
||||
scope.once.Do(func() {})
|
||||
scope.mu.Lock()
|
||||
defer scope.mu.Unlock()
|
||||
scope.closed = true
|
||||
if scope.request != nil {
|
||||
scope.request.Close()
|
||||
}
|
||||
@@ -52,24 +97,94 @@ func (s *Service) productScope(ctx context.Context, x session.Access) (context.C
|
||||
// ExecutionObservation never falls back to an arbitrary installed provider.
|
||||
// An out-of-scope, wrong-user or non-selected add-on has unknown status.
|
||||
func ExecutionObservation(ctx context.Context, user, addon string) (model.Value, string) {
|
||||
value, detail := ObserveExecution(ctx, user, addon)
|
||||
return value, detail.Explanation
|
||||
}
|
||||
|
||||
// ObserveExecution is ExecutionObservation with the whole detail. Failure says
|
||||
// why the value is unknown; Explanation and NetworkPolicyEnforced are set only
|
||||
// when the provider answered.
|
||||
func ObserveExecution(ctx context.Context, user, addon string) (model.Value, ExecutionDetail) {
|
||||
scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation)
|
||||
if !ok || user != scope.user {
|
||||
return model.Unknown(), ""
|
||||
return model.Unknown(), ExecutionDetail{Failure: UnknownNotSelected}
|
||||
}
|
||||
scope.once.Do(func() {
|
||||
if scope.provider != nil {
|
||||
scope.request, scope.addon, scope.explanation = scope.provider.NewExecutionObservation(scope.ctx, scope.user, scope.conversation)
|
||||
scope.mu.Lock()
|
||||
if !scope.closed && scope.provider != nil && (!scope.started || (scope.retriable && time.Since(scope.attempted) >= observationRetryAfter)) {
|
||||
// The first read of a scope stands. A turn can outlive a restart of the
|
||||
// add-on owner, so after a failure a restart may fix, one fresh
|
||||
// observation (a new binding and a new read) is allowed per interval.
|
||||
if scope.request != nil {
|
||||
scope.request.Close()
|
||||
}
|
||||
})
|
||||
if scope.request == nil || addon != scope.addon {
|
||||
return model.Unknown(), ""
|
||||
scope.request, scope.addon, scope.detail = scope.provider.NewExecutionObservation(scope.ctx, scope.user, scope.conversation)
|
||||
scope.started, scope.attempted, scope.retriable = true, time.Now(), false
|
||||
}
|
||||
value := scope.request.Observe(ctx)
|
||||
explanation := ""
|
||||
if value.Readiness != model.UnknownReadiness && scope.explanation != nil {
|
||||
explanation = scope.explanation()
|
||||
request, scopeAddon, detailOf := scope.request, scope.addon, scope.detail
|
||||
scope.mu.Unlock()
|
||||
read := func() ExecutionDetail {
|
||||
if detailOf == nil {
|
||||
return ExecutionDetail{}
|
||||
}
|
||||
return detailOf()
|
||||
}
|
||||
return value, explanation
|
||||
settle := func(v model.Value, d ExecutionDetail) (model.Value, ExecutionDetail) {
|
||||
scope.mu.Lock()
|
||||
scope.retriable = v.Readiness == model.UnknownReadiness && d.Failure.retriable()
|
||||
scope.mu.Unlock()
|
||||
return v, d
|
||||
}
|
||||
if request == nil {
|
||||
d := read()
|
||||
return settle(model.Unknown(), ExecutionDetail{Failure: cmp.Or(d.Failure, UnknownNotReported)})
|
||||
}
|
||||
if addon != scopeAddon {
|
||||
return model.Unknown(), ExecutionDetail{Failure: UnknownNotSelected}
|
||||
}
|
||||
value := request.Observe(ctx)
|
||||
d := read()
|
||||
if value.Readiness == model.UnknownReadiness {
|
||||
failure := cmp.Or(d.Failure, UnknownReportedUnknown)
|
||||
out := ExecutionDetail{Failure: failure}
|
||||
if failure == UnknownReportedUnknown {
|
||||
out.Explanation = d.Explanation
|
||||
}
|
||||
return settle(value, out)
|
||||
}
|
||||
d.Failure = ""
|
||||
return value, d
|
||||
}
|
||||
|
||||
// observationRetryAfter is the least time between two observations of one
|
||||
// scope. A test may lower it.
|
||||
var observationRetryAfter = 5 * time.Second
|
||||
|
||||
// retriable says whether a fresh observation may succeed where this failure
|
||||
// did. A refusal of the binding or of the payload will not change by itself.
|
||||
func (r UnknownReason) retriable() bool {
|
||||
return r == UnknownOwnerUnreachable || r == UnknownNoAnswer || r == UnknownStale
|
||||
}
|
||||
|
||||
// Text is the reason in a sentence for a person or a model, without the
|
||||
// subject: "its status could not be read: " + Text().
|
||||
func (r UnknownReason) Text() string {
|
||||
switch r {
|
||||
case UnknownOwnerUnreachable:
|
||||
return "the add-on owner could not be reached"
|
||||
case UnknownNotReported:
|
||||
return "the add-on has not reported its execution status"
|
||||
case UnknownStale:
|
||||
return "the status did not match the add-on's current version"
|
||||
case UnknownInvalid:
|
||||
return "the add-on sent a status the host could not accept"
|
||||
case UnknownNotSelected:
|
||||
return "the add-on is not the one that runs this chat's commands"
|
||||
case UnknownNoAnswer:
|
||||
return "the add-on did not answer in time"
|
||||
case UnknownReportedUnknown:
|
||||
return "the add-on reported that it cannot tell"
|
||||
}
|
||||
return "the reason is not known"
|
||||
}
|
||||
|
||||
// ExecutionStatusView is observation data, never execution authorization.
|
||||
@@ -77,20 +192,32 @@ type ExecutionStatusView struct {
|
||||
Readiness string `json:"readiness"`
|
||||
Isolation string `json:"isolation"`
|
||||
Explanation string `json:"explanation,omitempty"`
|
||||
Generation string `json:"generation,omitempty"`
|
||||
Incarnation string `json:"incarnation,omitempty"`
|
||||
// UnknownReason is set while the status is unknown: why the host could not
|
||||
// read one.
|
||||
UnknownReason string `json:"unknown_reason,omitempty"`
|
||||
// NetworkPolicyEnforced is true only when the provider reported that its
|
||||
// commands reach the network through the host's policy and the status is
|
||||
// available and isolated. The host uses it; the page does not.
|
||||
NetworkPolicyEnforced bool `json:"-"`
|
||||
Generation string `json:"generation,omitempty"`
|
||||
Incarnation string `json:"incarnation,omitempty"`
|
||||
}
|
||||
|
||||
func executionStatusView(ctx context.Context, user, addon string) *ExecutionStatusView {
|
||||
v, explanation := model.Unknown(), ""
|
||||
v, d := model.Unknown(), ExecutionDetail{Failure: UnknownNotSelected}
|
||||
if addon != "" {
|
||||
v, explanation = ExecutionObservation(ctx, user, addon)
|
||||
v, d = ObserveExecution(ctx, user, addon)
|
||||
}
|
||||
out := &ExecutionStatusView{Readiness: string(v.Readiness), Isolation: string(v.Isolation), Explanation: explanation}
|
||||
out := &ExecutionStatusView{Readiness: string(v.Readiness), Isolation: string(v.Isolation), Explanation: d.Explanation, UnknownReason: string(d.Failure),
|
||||
NetworkPolicyEnforced: d.NetworkPolicyEnforced && v.Readiness == model.Available && v.Isolation == model.Isolated}
|
||||
scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation)
|
||||
if ok && scope.user == user && scope.addon == addon && scope.request != nil {
|
||||
b := scope.request.Binding()
|
||||
out.Generation, out.Incarnation = b.Generation, b.Incarnation
|
||||
if ok && scope.user == user {
|
||||
scope.mu.Lock()
|
||||
if scope.addon == addon && scope.request != nil {
|
||||
b := scope.request.Binding()
|
||||
out.Generation, out.Incarnation = b.Generation, b.Incarnation
|
||||
}
|
||||
scope.mu.Unlock()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type observationProvider struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (p *observationProvider) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() string) {
|
||||
func (p *observationProvider) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() ExecutionDetail) {
|
||||
b := model.Binding{Actor: user, Addon: "renamed-provider", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"}
|
||||
r := executionrequest.New(ctx, b, "host-q", model.Eligible, executionrequest.IO{
|
||||
Validate: func(context.Context, model.Binding) error { return nil },
|
||||
@@ -50,7 +50,7 @@ func (p *observationProvider) NewExecutionObservation(ctx context.Context, user,
|
||||
if p.requests != nil {
|
||||
p.requests <- r
|
||||
}
|
||||
return r, b.Addon, func() string { return "" }
|
||||
return r, b.Addon, func() ExecutionDetail { return ExecutionDetail{} }
|
||||
}
|
||||
|
||||
var _ = Describe("execution status product scope", func() {
|
||||
@@ -159,3 +159,144 @@ var _ = Describe("execution status product scope", func() {
|
||||
Expect(v.Readiness != "unknown" || v.Isolation != "unknown" || v.Generation != "" || v.Incarnation != "").To(BeFalse(), "unbound status manufactured identity", v)
|
||||
})
|
||||
})
|
||||
|
||||
// reasonProvider returns a fixed observation, or none with a failure.
|
||||
type reasonProvider struct {
|
||||
AddonTools
|
||||
request *executionrequest.Request
|
||||
addon string
|
||||
detail func() ExecutionDetail
|
||||
}
|
||||
|
||||
func (p reasonProvider) NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() ExecutionDetail) {
|
||||
return p.request, p.addon, p.detail
|
||||
}
|
||||
|
||||
var _ = Describe("why an execution status is unknown", func() {
|
||||
view := func(p reasonProvider, addon string) *ExecutionStatusView {
|
||||
s := &Service{d: Deps{AddonTools: p}}
|
||||
ctx, done := s.executionScope(context.Background(), "alice", "chat")
|
||||
DeferCleanup(done)
|
||||
return executionStatusView(ctx, "alice", addon)
|
||||
}
|
||||
request := func(payload model.Payload, err error) *executionrequest.Request {
|
||||
b := model.Binding{Actor: "alice", Addon: "runner", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"}
|
||||
r := executionrequest.New(context.Background(), b, "q", model.Eligible, executionrequest.IO{
|
||||
Validate: func(context.Context, model.Binding) error { return nil },
|
||||
Read: func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
|
||||
return payload, err
|
||||
}}, nil)
|
||||
DeferCleanup(r.Close)
|
||||
return r
|
||||
}
|
||||
|
||||
It("carries the failure the provider recorded when it could not admit a request", func() {
|
||||
for _, reason := range []UnknownReason{UnknownOwnerUnreachable, UnknownNotReported, UnknownStale} {
|
||||
v := view(reasonProvider{detail: func() ExecutionDetail { return ExecutionDetail{Failure: reason} }}, "runner")
|
||||
Expect(v.Readiness).To(Equal("unknown"))
|
||||
Expect(v.UnknownReason).To(Equal(string(reason)))
|
||||
}
|
||||
})
|
||||
It("says the status was not reported when the provider recorded nothing", func() {
|
||||
Expect(view(reasonProvider{}, "runner").UnknownReason).To(Equal(string(UnknownNotReported)))
|
||||
})
|
||||
It("says the add-on does not run the chat's commands when another add-on was admitted", func() {
|
||||
p := reasonProvider{request: request(model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil), addon: "runner", detail: func() ExecutionDetail { return ExecutionDetail{} }}
|
||||
Expect(view(p, "other").UnknownReason).To(Equal(string(UnknownNotSelected)))
|
||||
Expect(view(p, "").UnknownReason).To(Equal(string(UnknownNotSelected)))
|
||||
})
|
||||
It("carries the failure of a read the add-on did not answer", func() {
|
||||
p := reasonProvider{request: request(model.InvalidPayload(), errors.New("boom")), addon: "runner",
|
||||
detail: func() ExecutionDetail { return ExecutionDetail{Failure: UnknownInvalid} }}
|
||||
Expect(view(p, "runner").UnknownReason).To(Equal(string(UnknownInvalid)))
|
||||
})
|
||||
It("says the add-on reported unknown when it answered unknown", func() {
|
||||
p := reasonProvider{request: request(model.ValidPayload(model.Unknown()), nil), addon: "runner", detail: func() ExecutionDetail { return ExecutionDetail{} }}
|
||||
Expect(view(p, "runner").UnknownReason).To(Equal(string(UnknownReportedUnknown)))
|
||||
})
|
||||
It("gives no reason once the status is known, and trusts enforcement only for available isolated", func() {
|
||||
for value, want := range map[model.Value]bool{
|
||||
{Readiness: model.Available, Isolation: model.Isolated}: true,
|
||||
{Readiness: model.Available, Isolation: model.Host}: false,
|
||||
} {
|
||||
p := reasonProvider{request: request(model.ValidPayload(value), nil), addon: "runner",
|
||||
detail: func() ExecutionDetail { return ExecutionDetail{NetworkPolicyEnforced: true, Failure: UnknownStale} }}
|
||||
v := view(p, "runner")
|
||||
Expect(v.UnknownReason).To(BeEmpty())
|
||||
Expect(v.NetworkPolicyEnforced).To(Equal(want))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// flakyProvider fails to admit its first observations, then succeeds.
|
||||
type flakyProvider struct {
|
||||
AddonTools
|
||||
failures int
|
||||
failure UnknownReason
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (p *flakyProvider) NewExecutionObservation(ctx context.Context, user, _ string) (*executionrequest.Request, string, func() ExecutionDetail) {
|
||||
if int(p.calls.Add(1)) <= p.failures {
|
||||
return nil, "", func() ExecutionDetail { return ExecutionDetail{Failure: p.failure} }
|
||||
}
|
||||
b := model.Binding{Actor: user, Addon: "runner", Generation: "2", Incarnation: "two", ExecutionTool: "run", StatusTool: "inspect"}
|
||||
r := executionrequest.New(ctx, b, "q", model.Eligible, executionrequest.IO{
|
||||
Validate: func(context.Context, model.Binding) error { return nil },
|
||||
Read: func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
|
||||
return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil
|
||||
}}, nil)
|
||||
return r, b.Addon, func() ExecutionDetail { return ExecutionDetail{} }
|
||||
}
|
||||
|
||||
var _ = Describe("observing again after the add-on owner restarted", func() {
|
||||
scopeOf := func(p ExecutionObserver) context.Context {
|
||||
s := &Service{d: Deps{AddonTools: p.(AddonTools)}}
|
||||
ctx, done := s.executionScope(context.Background(), "alice", "chat")
|
||||
DeferCleanup(done)
|
||||
return ctx
|
||||
}
|
||||
BeforeEach(func() {
|
||||
old := observationRetryAfter
|
||||
observationRetryAfter = 20 * time.Millisecond
|
||||
DeferCleanup(func() { observationRetryAfter = old })
|
||||
})
|
||||
|
||||
It("keeps the first answer inside the interval, then binds and reads again", func() {
|
||||
p := &flakyProvider{failures: 1, failure: UnknownOwnerUnreachable}
|
||||
ctx := scopeOf(p)
|
||||
v, d := ObserveExecution(ctx, "alice", "runner")
|
||||
Expect(v).To(Equal(model.Unknown()))
|
||||
Expect(d.Failure).To(Equal(UnknownOwnerUnreachable))
|
||||
_, _ = ObserveExecution(ctx, "alice", "runner")
|
||||
Expect(p.calls.Load()).To(BeEquivalentTo(1), "no second admission within the interval")
|
||||
time.Sleep(2 * observationRetryAfter)
|
||||
v, d = ObserveExecution(ctx, "alice", "runner")
|
||||
Expect(v.Isolation).To(Equal(model.Isolated))
|
||||
Expect(d.Failure).To(BeEmpty())
|
||||
Expect(p.calls.Load()).To(BeEquivalentTo(2))
|
||||
_, _ = ObserveExecution(ctx, "alice", "runner")
|
||||
Expect(p.calls.Load()).To(BeEquivalentTo(2), "a good status is not read again")
|
||||
})
|
||||
It("does not retry a refusal that waiting will not fix", func() {
|
||||
p := &flakyProvider{failures: 1, failure: UnknownNotReported}
|
||||
ctx := scopeOf(p)
|
||||
_, _ = ObserveExecution(ctx, "alice", "runner")
|
||||
time.Sleep(2 * observationRetryAfter)
|
||||
v, d := ObserveExecution(ctx, "alice", "runner")
|
||||
Expect(v).To(Equal(model.Unknown()))
|
||||
Expect(d.Failure).To(Equal(UnknownNotReported))
|
||||
Expect(p.calls.Load()).To(BeEquivalentTo(1))
|
||||
})
|
||||
It("does not start again once the scope is closed", func() {
|
||||
p := &flakyProvider{failures: 1, failure: UnknownStale}
|
||||
s := &Service{d: Deps{AddonTools: p}}
|
||||
ctx, done := s.executionScope(context.Background(), "alice", "chat")
|
||||
_, _ = ObserveExecution(ctx, "alice", "runner")
|
||||
done()
|
||||
time.Sleep(2 * observationRetryAfter)
|
||||
v, _ := ObserveExecution(ctx, "alice", "runner")
|
||||
Expect(v).To(Equal(model.Unknown()))
|
||||
Expect(p.calls.Load()).To(BeEquivalentTo(1))
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
"github.com/mudler/LocalAI/core/services/studio/skills"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// loadSkillName is the name of the tool that loads a skill by its name.
|
||||
@@ -107,33 +108,45 @@ func (s *Service) loadableSkills(ctx context.Context, a workspace.Actor) ([]skil
|
||||
}
|
||||
|
||||
func (s *Service) loadableSkillsWithCommands(ctx context.Context, a workspace.Actor, final *bool) ([]skills.Skill, error) {
|
||||
list, _, err := s.loadableSkillsAndNotes(ctx, a, final)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// loadableSkillsAndNotes also returns the notes of the resolution, which say
|
||||
// why a skill is missing.
|
||||
func (s *Service) loadableSkillsAndNotes(ctx context.Context, a workspace.Actor, final *bool) ([]skills.Skill, []string, error) {
|
||||
access, err := s.AddonAccess(ctx, a.Principal, a.ChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return s.loadableSkillsForActor(ctx, access.Actor(), final)
|
||||
return s.loadableSkillsAndNotesForActor(ctx, access.Actor(), final)
|
||||
}
|
||||
|
||||
// loadableSkillsForActor is for initial tool construction, using its already
|
||||
// authorized actor. Public picker and tool calls resolve fresh access instead.
|
||||
func (s *Service) loadableSkillsForActor(ctx context.Context, a workspace.Actor, final *bool) ([]skills.Skill, error) {
|
||||
list, _, err := s.loadableSkillsAndNotesForActor(ctx, a, final)
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (s *Service) loadableSkillsAndNotesForActor(ctx context.Context, a workspace.Actor, final *bool) ([]skills.Skill, []string, error) {
|
||||
rec, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
sc := s.scopeOf(ctx, rec)
|
||||
command, err := s.addonCommandDescriptorForActor(ctx, a, rec, sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if final != nil && !*final {
|
||||
command = Commands{State: CommandsNone}
|
||||
}
|
||||
list, _, _, err := s.chatSkillSet(ctx, a.Principal, rec.SkillsOff, rec.AddonsOff, sc, command)
|
||||
list, notes, _, err := s.chatSkillSet(ctx, a.Principal, rec.SkillsOff, rec.AddonsOff, sc, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return skills.Usable(list), nil
|
||||
return skills.Usable(list), notes, nil
|
||||
}
|
||||
|
||||
// loadSkillRun is the Run of load_skill. It resolves the skills again at each
|
||||
@@ -147,10 +160,18 @@ func (s *Service) loadSkillRun(a workspace.Actor, o loadOpts) func(context.Conte
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
list, err := s.loadableSkillsWithCommands(ctx, a, o.sandbox)
|
||||
list, notes, err := s.loadableSkillsAndNotes(ctx, a, o.sandbox)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the skills cannot be read now: %w", err)
|
||||
}
|
||||
// The tool was offered because the chat had this skill; a document
|
||||
// skill is missing now only because the Sandbox is not known to be
|
||||
// isolated, and the model must say so rather than "no skills".
|
||||
if _, ok := skills.Find(list, in.Name); !ok && contract.DocumentSkill(skills.Clean(in.Name)) {
|
||||
if i := slices.IndexFunc(notes, func(n string) bool { return strings.HasPrefix(n, documentNotePrefix) }); i >= 0 {
|
||||
return "", fmt.Errorf("unknown skill %q: %s", skills.Clean(in.Name), notes[i])
|
||||
}
|
||||
}
|
||||
return s.loadSkill(ctx, a.Principal, list, in.Name, o)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s *Service) chatSkillSet(ctx context.Context, p string, off, addonsOff []s
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
return list, notes, ad.markFallbacks(list), nil
|
||||
return list, ad.withNotes(notes), ad.markFallbacks(list), nil
|
||||
}
|
||||
|
||||
// Skills lists the skills the user has: every layer, with shadowed and skipped
|
||||
@@ -211,7 +211,7 @@ func (s *Service) Skills(ctx context.Context, user, conv string) ([]skills.Skill
|
||||
return nil, nil, err
|
||||
}
|
||||
ad.markFallbacks(list)
|
||||
return list, notes, nil
|
||||
return list, ad.withNotes(notes), nil
|
||||
}
|
||||
|
||||
// WellKnown returns the well-known files of the users' homes: persona.json,
|
||||
|
||||
Reference in new issue
Block a user