@@ -17,58 +17,66 @@ module Siza.Agent.Futility (
1717 unchangedState ,
1818) where
1919
20- import Data.Aeson (Value (.. ), encode , object , toJSON , (.=) )
20+ import Data.Aeson (Value (.. ), encode , object , (.=) )
2121import qualified Data.Aeson.Key as K
2222import qualified Data.Aeson.KeyMap as KM
2323import qualified Data.ByteString.Lazy as LBS
24- import Data.Char (isDigit )
2524import Data.IORef (IORef , atomicModifyIORef' , newIORef , readIORef )
2625import Data.Map.Strict (Map )
2726import qualified Data.Map.Strict as Map
2827import Data.Maybe (fromMaybe , listToMaybe )
29- import Data.Set (Set )
3028import qualified Data.Set as Set
3129import Data.Text (Text )
3230import qualified Data.Text as T
3331import qualified Data.Text.Encoding as TE
3432import qualified Data.Text.Encoding.Error as TEE
3533
36- import Sabela.AI.SelfHeal (sourceDelta )
3734import Sabela.AI.Types (ToolOutcome (.. ))
3835import Sabela.LLM.Ollama.Client (ToolCall (.. ))
39-
40- {- | What a diagnostic class has already cost: how many calls it answered, the
41- submitted sources it answered them for, and the most recent of those.
42- -}
43- data RejectionRun = RejectionRun
44- { rrCount :: ! Int
45- , rrSources :: ! (Set Text )
46- , rrLast :: ! Text
47- }
36+ import Siza.Agent.Futility.Rejection (
37+ CachedRejection (.. ),
38+ RejectionRun (.. ),
39+ cacheableRejection ,
40+ cachedFact ,
41+ completeWrite ,
42+ markUnchanged ,
43+ normaliseDiagnostic ,
44+ settledNoMutation ,
45+ unchangedState ,
46+ worldChanging ,
47+ )
48+ import Siza.Agent.VerifyMemo (Seal , currentSeal )
4849
4950data GuardState = GuardState
5051 { gsCalls :: ! (Map (Text , Text ) Text )
5152 , gsRuns :: ! (Map Text RejectionRun )
53+ , gsEpochRuns :: ! (Map Text RejectionRun )
54+ , gsCached :: ! (Map (Text , Text ) CachedRejection )
55+ , gsEpoch :: ! Int
5256 }
5357
5458newtype FutilityGuard = FutilityGuard (IORef GuardState )
5559
5660newFutilityGuard :: IO FutilityGuard
57- newFutilityGuard = FutilityGuard <$> newIORef (GuardState Map. empty Map. empty)
61+ newFutilityGuard =
62+ FutilityGuard
63+ <$> newIORef
64+ GuardState
65+ { gsCalls = Map. empty
66+ , gsRuns = Map. empty
67+ , gsEpochRuns = Map. empty
68+ , gsCached = Map. empty
69+ , gsEpoch = 0
70+ }
5871
5972futilityNote :: Text
6073futilityNote =
61- " This call was byte-identical to an earlier call and failed with the \
62- \identical error. Re-sending or re-phrasing the same payload will not \
63- \change the outcome - the payload is not the fault. Change approach: \
64- \check kernel_status / list_cells, use a different tool, or take a \
65- \smaller step."
74+ " This call's name and arguments match an earlier call, and the recorded \
75+ \error is identical."
6676
6777sourceFaultNote :: Text
6878sourceFaultNote =
69- " This exact source was rejected before with the identical diagnostic. It \
70- \is deterministic: the fault is in the source, not the kernel or the \
71- \environment. Read the diagnostic above and change the source it names."
79+ " This exact source was rejected before with the identical compiler diagnostic."
7280
7381noteFor :: Either Text ToolOutcome -> Text
7482noteFor out
@@ -88,7 +96,48 @@ guardDispatch ::
8896 ToolCall ->
8997 IO (Either Text ToolOutcome )
9098guardDispatch (FutilityGuard ref) dispatch call = do
91- out <- dispatch call
99+ reused <- validatedCache ref dispatch call
100+ maybe fresh pure reused
101+ where
102+ fresh = do
103+ out <- dispatch call
104+ seal <- rejectionSeal dispatch call out
105+ _ <- atomicModifyIORef' ref (afterDispatch call out seal)
106+ observe False ref call out
107+
108+ validatedCache ::
109+ IORef GuardState ->
110+ (ToolCall -> IO (Either Text ToolOutcome )) ->
111+ ToolCall ->
112+ IO (Maybe (Either Text ToolOutcome ))
113+ validatedCache ref dispatch call = do
114+ cached <- Map. lookup (callKey call) . gsCached <$> readIORef ref
115+ case cached of
116+ Just c | completeWrite call -> do
117+ seal <- currentSeal dispatch
118+ if seal == Just (cachedSeal c)
119+ then Just <$> observe True ref call (Right (cachedOutcome c))
120+ else do
121+ atomicModifyIORef' ref (\ s -> (invalidateWorld s, () ))
122+ pure Nothing
123+ _ -> pure Nothing
124+
125+ rejectionSeal ::
126+ (ToolCall -> IO (Either Text ToolOutcome )) ->
127+ ToolCall ->
128+ Either Text ToolOutcome ->
129+ IO (Maybe Seal )
130+ rejectionSeal dispatch call out
131+ | completeWrite call && cacheableRejection out = currentSeal dispatch
132+ | otherwise = pure Nothing
133+
134+ observe ::
135+ Bool ->
136+ IORef GuardState ->
137+ ToolCall ->
138+ Either Text ToolOutcome ->
139+ IO (Either Text ToolOutcome )
140+ observe reused ref call out = do
92141 let key = callKey call
93142 src = submittedSource call
94143 mClass = diagnosticClass out
@@ -103,7 +152,43 @@ guardDispatch (FutilityGuard ref) dispatch call = do
103152 annotated
104153 | prevFt == Just ft = annotate (noteFor out) marked
105154 | otherwise = marked
106- pure annotated
155+ epoch <- gsEpoch <$> readIORef ref
156+ pure (if reused then cachedFact epoch annotated else annotated)
157+
158+ afterDispatch ::
159+ ToolCall ->
160+ Either Text ToolOutcome ->
161+ Maybe Seal ->
162+ GuardState ->
163+ (GuardState , Int )
164+ afterDispatch call out seal s
165+ | worldChanging call && not (settledNoMutation out) =
166+ let s' = invalidateWorld s
167+ in (s', gsEpoch s')
168+ | completeWrite call
169+ , cacheableRejection out
170+ , Right rejected <- out =
171+ case seal of
172+ Just observed ->
173+ ( s
174+ { gsCached =
175+ Map. insert
176+ (callKey call)
177+ (CachedRejection rejected observed)
178+ (gsCached s)
179+ }
180+ , gsEpoch s
181+ )
182+ Nothing -> (s, gsEpoch s)
183+ | otherwise = (s, gsEpoch s)
184+
185+ invalidateWorld :: GuardState -> GuardState
186+ invalidateWorld s =
187+ s
188+ { gsEpochRuns = Map. empty
189+ , gsCached = Map. empty
190+ , gsEpoch = gsEpoch s + 1
191+ }
107192
108193forgetCall :: (Text , Text ) -> GuardState -> GuardState
109194forgetCall key s = s{gsCalls = Map. delete key (gsCalls s)}
@@ -122,12 +207,13 @@ record ::
122207record key ft mClass src s = (s', (Map. lookup key (gsCalls s), prevRun))
123208 where
124209 s' =
125- GuardState
210+ s
126211 { gsCalls = Map. insert key ft (gsCalls s)
127- , gsRuns = maybe (gsRuns s) bump mClass
212+ , gsRuns = maybe (gsRuns s) (bump (gsRuns s)) mClass
213+ , gsEpochRuns = maybe (gsEpochRuns s) (bump (gsEpochRuns s)) mClass
128214 }
129- prevRun = flip Map. lookup (gsRuns s) =<< mClass
130- bump cls = Map. insert cls (extend prevRun) (gsRuns s)
215+ prevRun = flip Map. lookup (gsEpochRuns s) =<< mClass
216+ bump runs cls = Map. insert cls (extend ( Map. lookup cls runs)) runs
131217 extend Nothing = RejectionRun 1 (Set. singleton src) src
132218 extend (Just r) =
133219 RejectionRun (rrCount r + 1 ) (Set. insert src (rrSources r)) src
@@ -139,26 +225,6 @@ rejectionRepeats :: FutilityGuard -> IO (Map Text Int)
139225rejectionRepeats (FutilityGuard ref) =
140226 Map. map (subtract 1 . rrCount) . gsRuns <$> readIORef ref
141227
142- {- | A diagnostic with its @\<interactive\>@ positions erased. Two rejections
143- that differ only in where the session happened to place the candidate are the
144- same diagnostic, and the model cannot act on the difference.
145- -}
146- normaliseDiagnostic :: Text -> Text
147- normaliseDiagnostic t = case T. breakOn interactiveMarker t of
148- (_, rest) | T. null rest -> t
149- (pre, rest) ->
150- let body = T. drop (T. length interactiveMarker) rest
151- (pos, after) = T. span positionChar body
152- in pre
153- <> interactiveMarker
154- <> (if T. null pos then " " else " L:C" )
155- <> normaliseDiagnostic after
156- where
157- positionChar c = isDigit c || c == ' :' || c == ' -'
158-
159- interactiveMarker :: Text
160- interactiveMarker = " <interactive>:"
161-
162228{- | The diagnostic class a deterministic rejection belongs to. Only a
163229rejection carrying a diagnostic has one: an outcome with no diagnostic gives
164230the guard nothing to compare.
@@ -171,74 +237,6 @@ diagnosticClass out@(Right (ToolErr (Object o)))
171237 Just (normaliseDiagnostic d)
172238diagnosticClass _ = Nothing
173239
174- {- | Say that this diagnostic is the one an earlier call already produced, and
175- over how many distinct sources. Both numbers are counted here; nothing is
176- claimed about where the cause is.
177- -}
178- markUnchanged ::
179- Text -> Either Text ToolOutcome -> RejectionRun -> Either Text ToolOutcome
180- markUnchanged src (Right (ToolErr (Object o))) prev =
181- Right (ToolErr (Object (KM. insert (K. fromText " unchanged" ) detail marked)))
182- where
183- marked = KM. insert (K. fromText " state" ) (String unchangedState) o
184- detail =
185- object
186- ( [ " priorCalls" .= rrCount prev
187- , " distinctSources" .= Set. size sources
188- , " sourceChanged" .= changed
189- , " note" .= unchangedNote (rrCount prev) (Set. size sources)
190- ]
191- <> changedLinesPairs
192- )
193- sources = Set. filter (not . T. null ) (Set. insert src (rrSources prev))
194- changed = src /= rrLast prev
195- (removed, added) = sourceDelta (rrLast prev) src
196- dropped =
197- length removed
198- + length added
199- - length (take deltaLines removed)
200- - length (take deltaLines added)
201- changedLinesPairs
202- | not changed = []
203- | otherwise =
204- [ " changedLines"
205- .= object
206- ( [ " removed" .= toJSON (take deltaLines removed)
207- , " added" .= toJSON (take deltaLines added)
208- ]
209- <> [" furtherLines" .= dropped | dropped > 0 ]
210- )
211- ]
212- markUnchanged _ out _ = out
213-
214- -- | How many changed lines either side of the delta is worth carrying.
215- deltaLines :: Int
216- deltaLines = 12
217-
218- unchangedState :: Text
219- unchangedState = " unchanged"
220-
221- {- | Only what was counted: how many calls this diagnostic answered, and how
222- many different non-empty sources those calls submitted.
223- -}
224- unchangedNote :: Int -> Int -> Text
225- unchangedNote priorCalls distinct =
226- " This diagnostic is identical, ignoring <interactive> line and column \
227- \numbers, to the one "
228- <> tshow priorCalls
229- <> " earlier call(s) in this session produced."
230- <> sourceSentence
231- where
232- sourceSentence
233- | distinct <= 0 = " "
234- | otherwise =
235- " Counting this call, "
236- <> tshow distinct
237- <> " distinct submitted source(s) have produced it."
238-
239- tshow :: (Show a ) => a -> Text
240- tshow = T. pack . show
241-
242240callKey :: ToolCall -> (Text , Text )
243241callKey (ToolCall n a) = (n, encodeText a)
244242
0 commit comments